From 1e1f79db0dc157c7498222ca45c7384084f95b14 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:09:34 +0200 Subject: [PATCH 001/247] [Windows] Wait for uninstaller, rework .dll bundling, add logging (#7407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Windows] Purge every runtime DLL on upgrade instead of a named few A user updating from 3.0.2 to 3.1 got "abseil_dll.dll is either not designed to run on Windows or it contains an error" and could not start the client. abseil_dll.dll comes from vcpkg, not Qt. It rides into the install directory on the "*.dll" glob in cockatrice/CMakeLists.txt, alongside libprotobuf.dll, liblzma.dll and z.dll, and none of those were named in the upgrade purge. So on an upgrade the old uninstaller's RMDir /r skipped whichever of them was locked, the purge did not look for them, HasStaleRuntimeFiles did not look for them either, and the installer reported success - having written the new executables next to the previous version's protobuf runtime. Windows then refused to load it. The mask list was also already wrong where it existed: it matches zlib*.dll while vcpkg ships z.dll. Delete every top-level DLL instead of naming library families. The payload is written after the purge, so a DLL the new build still needs is put straight back; what the purge has to guarantee is only that nothing from the previous build is left to be loaded next to the new executables. Enumerated families cannot do that - every transitive dependency that arrives between two releases is one more name to forget, and the three previous rounds of this work each reasoned about Qt because that is what the reports named at the time. HasStaleRuntimeFiles collapses to the matching *.dll probe, so the abort fires on any survivor rather than only on the families it was told about. IsExistingCockatriceInstall stays on Qt6*.dll on purpose: it decides whether a directory is ours at all, and a folder that merely holds some DLLs of its own must not be purged. Qt6*.dll is enough even when only the protobuf runtime survived, because the whole Qt set is mapped by the same process that maps abseil_dll.dll and so is locked or free with it. * [CI] Assert the whole Windows runtime, not just the Qt modules required-qt-runtime.txt listed only the Qt modules COCKATRICE_QT_MODULES names, so it could not see any other runtime go missing. The three previous rounds of installer work all reasoned about Qt*.dll because that was all the build knew how to ask about, and the one runtime that actually broke the reported upgrade - abseil_dll.dll - was outside the set entirely. - Rename it to required-runtime.txt and take the third party runtime names from the vcpkg bin directory the app-local copies come from, so a dependency bump cannot leave the list behind and a versioned name such as libcrypto-3-x64.dll becoming libcrypto-4-x64.dll cannot either - Union the three applications' module lists rather than the client's alone: Servatrice contributes Qt6Sql.dll, servatrice.exe is installed next to cockatrice.exe and cannot start without it, and nothing asserted it - Add Core to the client's module list. It is the one module nothing can do without and it only ever arrived transitively, so Qt6Core.dll was shipped and never checked - the new unknown-runtime check flagged it on the first run - Fail the build on a missing runtime, as before, and warn on a runtime nothing requires. Only the test framework is a hard failure there: "no shipped executable links gtest" is a fact, whereas a runtime missing from the manifest may be one the manifest failed to derive, and a check that has never run before should not be able to block a release The warning is meant to be read. Qt6::Quick pulls in Qml, QmlModels, QmlWorkerScript, QuickBasic, QuickTemplates2 and OpenGL transitively and none are named, so the manifest is known to be a partial list; the first Windows run reports the real one, which is evidence rather than a guess about what Qt links. Fold that back in when it lands. * [CI] Stop deriving the required runtime from the vcpkg bin directory The first Windows run of this check failed, and the seven files it named as missing are not runtime at all: gmock.dll, gmock_main.dll, legacy.dll, libprotobuf-lite.dll, libprotoc.dll and pthreadVC3/VCE3/VSE3.dll. None of them is linked by any shipped executable, so a correct package leaves them out. vcpkg_installed/x64-windows/bin is everything vcpkg installed, not everything the applications import. The protoc compiler, the GoogleMock library, a port tool and three CMake FindThreads probes sit beside the genuine runtimes, and a directory listing cannot tell them apart. Asserting it fails the build over a package that is right - which is what happened, and it is the same mistake as the one that produced this bug report: reasoning about a list of names instead of about the build. The real source of truth is the three executables' import tables, which fixup_bundle already walks to decide what to copy. Until CMake can read that result, the Qt half is what can be derived honestly, from the same module lists the targets are built against. So assert that, and report the rest rather than require it: - Drop the vcpkg bin glob, its gtest filter and the vcpkg-runtime-known marker. All three existed only to support the glob. - Report top level DLLs the manifest does not name without failing. These are expected: protobuf, Abseil, OpenSSL, liblzma, ffmpeg and the Qt debug DLLs all arrive on the build output glob because nothing in the build declares them by name. The list is a report of what the package carries, not a verdict, and it is how the missing Qt6Qml/Quick/OpenGL entries above became visible. - Report a leaked gtest or gmock instead of failing on it. "No shipped executable links this" is a fact, but a check that has never passed on a real package has not earned the right to block a release, and the first run of an enforcing version is exactly when its own wrong assumptions show up. - Skip DLLs under a subdirectory, which are platform plugins rather than imported runtimes, and the NSIS plugins, which extract to $PLUGINSDIR and so reach the comparison looking like payload. Verified against the paths 7-Zip actually listed in the failing package: the check passes on it, still fails when Qt6Sql.dll or the qwindows plugin is removed, and still reports gtest.dll and gmock.dll if either is added. The purge in cmake/NSIS.template.in covers the runtimes the manifest does not name. That fix is independent of any list and is what makes the reported update failure go away. * [Installer] Fix the /R upgrade aborting before it does anything WaitForLauncherToExit read the wrong registers. System.dll writes its .rN outputs to $N and its .RN outputs to $RN, but the wait used p.r0/i.r1 while testing $R1. $R1 therefore stayed empty, and the "did not exit" test compared "" with "0" - true every time. Every in-app update passes /PID=, so every upgrade hit AbortInstall before writing anything; because /R also sets silent, the override just vanished with exit code 1 and no message. The bug came in with #7372 and is inherited by this branch, not introduced by it. Use the uppercase .RN forms, and treat a null or plugin-failed OpenProcess as the launcher already being gone - the case the old ${Errors} check never caught, because System.dll does not set the NSIS error flag from the callee's return value. Also restore the abort dialog for the in-app updater: AbortInstall and the two inline warnings gated their message box on ${IfNot} ${Silent}, which is false for a /R upgrade, so any abort on that path was equally silent. The unattended case is a /S install without /R, not /R, so show the box for $ReinstallMode = 1 or an interactive install. The stale-runtime abort now goes through AbortInstall so that decision lives in one place. * [Installer] Log every upgrade step to %TEMP%\Cockatrice-installer.log * [Installer] Actually append to the upgrade log (FileSeek END) * [Installer] Wait for the old uninstaller before replacing files An NSIS uninstaller copies itself to %TEMP% and runs from there so that it can delete its own directory; the process the installer started exits immediately. A plain ExecWait therefore returns before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" then executes concurrently with the install section, deleting files the payload just wrote. That is why an upgrade could leave Qt6Core/Gui/Multimedia and the other alphabetically-first DLLs missing while the tail of the directory survived: a recursive delete marching from the front while the final listing ran. Pass _?=$INSTDIR to the old uninstaller so it runs in place and ExecWait actually blocks until the uninstall completes. _?= must be the last argument and unquoted, even for paths with spaces. Applied to both the silent /R path (AutoUninstallIfNeeded) and the interactive path (componentsPagePre). * [Installer] Tighten the wording of the upgrade comments Drop the dash-heavy phrasing from the comments this PR adds, and say plainly in the _?= comment that ExecWait returns before the old uninstaller has run and the temp copy then deletes the payload the section is still writing. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 93 +++++++++++-- CMakeLists.txt | 56 +++++--- cmake/FindQtRuntime.cmake | 7 + cmake/NSIS.template.in | 265 +++++++++++++++++++++++++++----------- cockatrice/CMakeLists.txt | 2 +- 5 files changed, 316 insertions(+), 107 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 72a017f33..823a31898 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -353,15 +353,21 @@ if [[ $MAKE_PACKAGE ]]; then fi echo "Installer content is clean" - # Fail the build if the installer is missing a Qt runtime the applications - # link against. The DLLs reach the package by globbing every *.dll out of - # the build output directories, so a module the client links statically - # (Qt6Multimedia.dll, Qt6QuickWidgets.dll) can be left out without anything - # else noticing, and the only symptom is a client that refuses to start - # after an update. required-qt-runtime.txt is written by the top level - # CMakeLists.txt from the same module list the targets are built against. - if [[ ! -f required-qt-runtime.txt ]]; then - echo "::error file=$0::required-qt-runtime.txt not found in the build dir, cannot verify the Qt runtime" + # Fail the build if the installer does not carry the runtime the + # applications link against - a file that must be there, or a DLL that must + # not be. The runtime DLLs reach the package by globbing every *.dll out of + # the build output directories, so anything that build produced can ride + # along and nothing else in the build objects to it: a Qt module the client + # links statically (Qt6Multimedia.dll, Qt6QuickWidgets.dll) can be left out, + # and the test framework's gtest.dll can be shipped. Either only surfaces on + # a user's machine after an update has already replaced their install, as + # "The code execution cannot proceed because Qt6Multimedia.dll was not + # found" or "abseil_dll.dll is either not designed to run on Windows or it + # contains an error". required-runtime.txt is written by the top level + # CMakeLists.txt from the same module lists the targets are built against + # plus the vcpkg bin directory the app-local copies come from. + if [[ ! -f required-runtime.txt ]]; then + echo "::error file=$0::required-runtime.txt not found in the build dir, cannot verify the runtime" exit 1 fi # 7-Zip writes CRLF on Windows, so the \r has to come off before any @@ -371,7 +377,7 @@ if [[ $MAKE_PACKAGE ]]; then # being a wildcard; -i because that is how the installed tree behaves - # the uninstaller clears both $INSTDIR\plugins and $INSTDIR\Plugins. # The archive side and the manifest side have to agree on line endings. - # CMake writes required-qt-runtime.txt with the platform's line ending and + # CMake writes required-runtime.txt with the platform's line ending and # 7-Zip writes CRLF on Windows, so a trailing \r survives on $entry and # defeats the whole-line match - every entry then reads as missing even # though the listing holds it verbatim. @@ -380,7 +386,7 @@ if [[ $MAKE_PACKAGE ]]; then installer_paths="$("$seven_zip" l -slt "$package" | tr -d '\r' | sed -n 's/^Path = //p' | sed -e 's|\\|/|g' -e 's|^[$]INSTDIR/||' -e 's|^[$]OUTDIR/||' -e 's|^[$]PLUGINSDIR/||' -e 's|^\./||' -e 's|^/||')" - manifest="$(tr -d '\r' . Used to record the DLL inventory +; before and after the upgrade purge, which is what shows whether Qt6Gui.dll was +; on disk and what (if anything) survived the deletes. +!macro CockLogListing label mask + Push "${label}" + Push "$INSTDIR\${mask}" + Call CockLogListing +!macroend + +Function CockLogListing + Pop $LogMatchPattern + Pop $LogMatchLabel + !insertmacro CockLog "listing [$LogMatchLabel] pattern=$LogMatchPattern" + FindFirst $LogFindHandle $LogFindName "$LogMatchPattern" + ${If} $LogFindHandle == "" + !insertmacro CockLog "listing [$LogMatchLabel] (no matches)" + Return + ${EndIf} + log_listing_loop: + !insertmacro CockLog "listing [$LogMatchLabel] $LogFindName" + ClearErrors + FindNext $LogFindHandle $LogFindName + IfErrors 0 log_listing_loop + FindClose $LogFindHandle +FunctionEnd + Function .onInit +StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log" +; 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 +FileClose $LogHandle +!insertmacro CockLog "==================================================================" +!insertmacro CockLog "installer start: @CPACK_PACKAGE_NAME@ @CPACK_PACKAGE_VERSION@" + ${If} ${NSIS_IS_64_BIT} == 1 #NSIS 64bit ${IfNot} ${RunningX64} MessageBox MB_OK|MB_ICONSTOP "This version of Cockatrice requires a 64-bit Windows system." @@ -158,6 +224,13 @@ ${EndIf} ; Now that $PortableMode reflects reality, commit InstDir into the correct slot Call SetModeDestinationFromInstdir +!insertmacro CockLog "options: InstDir=$InstDir PortableMode=$PortableMode ReinstallMode=$ReinstallMode WaitPid=$WaitPid" +${If} ${Silent} + !insertmacro CockLog "options: running silently" +${Else} + !insertmacro CockLog "options: interactive" +${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 @@ -175,12 +248,14 @@ Call SetModeDestinationFromInstdir ; interactive installs the section re-checks after the directory page, when ; $INSTDIR is the directory the user actually picked. ${If} $ReinstallMode = 1 + !insertmacro CockLog "reinstall: waiting for launcher and other instances" Call WaitForLauncherToExit Call EnsureAppsNotRunning ${EndIf} ${If} $ReinstallMode = 1 ${AndIf} $PortableMode = 0 + !insertmacro CockLog "reinstall: auto-uninstalling previous version" Call AutoUninstallIfNeeded ${EndIf} @@ -226,13 +301,22 @@ Function AutoUninstallIfNeeded SetShellVarContext all +; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller +; normally copies itself to %TEMP% and runs from there so that it can delete its +; own directory, and the process we started exits at once. ExecWait then returns +; before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" executes +; while the section below is still writing the new payload. _?= disables the copy +; and makes ExecWait block until the uninstall finishes. It must be the last +; argument and must not be quoted, even when the path contains spaces. +; ; --- 32-bit uninstall --- SetRegView 32 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" +!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." -ExecWait '$R0' +ExecWait '$R0 _?=$INSTDIR' done32: @@ -241,9 +325,10 @@ ${If} ${RunningX64} SetRegView 64 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." - ExecWait '$R0' + ExecWait '$R0 _?=$INSTDIR' done64: ${EndIf} @@ -360,6 +445,7 @@ 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 @@ -384,6 +470,7 @@ Function WaitForAppToClose Sleep 500 Call IsAppRunning ${If} $R0 = 0 + !insertmacro CockLog "guard $R1: closed gracefully" DetailPrint "$R1 closed." Return ${EndIf} @@ -402,6 +489,7 @@ Function WaitForAppToClose ; 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." 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." @@ -410,11 +498,13 @@ Function WaitForAppToClose ; 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} @@ -422,6 +512,7 @@ Function WaitForAppToClose 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." @@ -486,8 +577,16 @@ FunctionEnd ; Only the first of those two gets a dialog; the second reports through the log ; and the exit code. Function AbortInstall - ${IfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason" + !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" ${EndIf} SetErrorLevel 1 Abort @@ -510,17 +609,33 @@ FunctionEnd ; falls back to the name based guard. Function WaitForLauncherToExit StrCpy $LauncherExited 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. ClearErrors - System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.r0' + System::Call 'kernel32::OpenProcess(i 0x00100000, 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 + ; passed straight to WaitForSingleObject. Fold a plugin failure into the same + ; null-handle case instead of reading a handle that was never written. ${If} ${Errors} + StrCpy $R0 0 + ${EndIf} + ${If} $R0 == 0 ; The launcher is gone - OpenProcess cannot open a dead process - which is ; exactly the state the wait was for. + !insertmacro CockLog "wait-launcher: already exited" DetailPrint "The application that started this installer has already exited." StrCpy $LauncherExited 1 Return @@ -529,14 +644,16 @@ Function WaitForLauncherToExit ; 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 60000) i.R1' + System::Call 'kernel32::CloseHandle(p R0)' ${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." 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 ${EndIf} + !insertmacro CockLog "wait-launcher: launcher exited" DetailPrint "The application that started this installer has exited." StrCpy $LauncherExited 1 FunctionEnd @@ -562,9 +679,11 @@ 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 @@ -587,7 +706,8 @@ Function GuardAppsNotRunning ${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. - ${IfNot} ${Silent} + ${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 @@ -633,7 +753,11 @@ Function IsExistingCockatriceInstall ; there to prove that it is one. Every Qt module Cockatrice links is part of ; the payload, so match them all: probing only Qt6Core.dll would miss a ; directory where that one file happened to be deletable and the others - ; were not. + ; were not. Qt6*.dll rather than *.dll on purpose: this function decides + ; whether a directory is ours, and a folder that merely holds some DLLs of + ; its own must not be purged. Qt6*.dll is enough even when only the protobuf + ; runtime survived, because the whole Qt set is mapped by the same process + ; that maps abseil_dll.dll and so is locked or free with it. StrCpy $R3 "Qt6*.dll" Call CheckStaleRuntimeMask ${If} ${FileExists} "$INSTDIR\qt.conf" @@ -645,36 +769,23 @@ FunctionEnd ; the purge. NSIS silently skips whatever it cannot delete, so one surviving file ; is enough to prove that something still holds a lock on the install directory. ; -; The masks below have to cover the purge above one for one. A check that only -; probed named files could not see a locked Qt6Multimedia.dll or a Plugins -; directory the purge was unable to remove, so the exact files that broke the -; install were also the ones this check was blind to. +; Any DLL at all counts, not a list of expected ones. The purge removes every +; top-level DLL, so after it has run the only DLL that can still be there is one +; it failed to delete, whatever its name. A mask per library family (Qt, then +; OpenSSL, then zlib) is what let abseil_dll.dll through: it is in neither the +; purge nor this check, so it survived a 3.0.2 to 3.1 update untouched, the new +; executables were written next to it, and the client then failed to start with +; "abseil_dll.dll is either not designed to run on Windows or it contains an +; error". Probing *.dll has no list to fall out of date when the next +; transitive dependency appears. Function HasStaleRuntimeFiles StrCpy $R0 0 - StrCpy $R3 "Qt*.dll" + StrCpy $R3 "*.dll" Call CheckStaleRuntimeMask - StrCpy $R3 "libcrypto*.dll" - Call CheckStaleRuntimeMask - StrCpy $R3 "libssl*.dll" - Call CheckStaleRuntimeMask - StrCpy $R3 "zlib*.dll" - Call CheckStaleRuntimeMask - StrCpy $R3 "icu*.dll" - Call CheckStaleRuntimeMask - StrCpy $R3 "libeay32.dll" - Call CheckStaleRuntimeMask - StrCpy $R3 "ssleay32.dll" - Call CheckStaleRuntimeMask - ${If} $R0 = 1 - Return - ${EndIf} - ${If} ${FileExists} "$INSTDIR\libmysql.dll" - StrCpy $R0 1 - Return - ${EndIf} ; IfFileExists takes wildcards, so a Plugins directory the purge could not ; remove counts as a leftover too. ${If} ${FileExists} "$INSTDIR\Plugins\*.*" + !insertmacro CockLog "stale-runtime: Plugins directory survived the purge" StrCpy $R0 1 ${EndIf} FunctionEnd @@ -687,6 +798,7 @@ Function CheckStaleRuntimeMask ${EndIf} FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\$R3" ${If} $StaleFindHandle != "" + !insertmacro CockLog "stale-runtime: '$StaleFindName' matched $R3 in $INSTDIR" StrCpy $R0 1 FindClose $StaleFindHandle ${EndIf} @@ -925,9 +1037,10 @@ ${If} $PortableMode = 0 ; directory before letting it delete anything. The uninstall string is ; stashed away first, the check works on $R0. StrCpy $OldUninstallString $R0 + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning ClearErrors - ExecWait "$OldUninstallString" + ExecWait "$OldUninstallString _?=$INSTDIR" done32: @@ -947,9 +1060,10 @@ ${If} $PortableMode = 0 uninst64: StrCpy $OldUninstallString $R0 + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning ClearErrors - ExecWait "$OldUninstallString" + ExecWait "$OldUninstallString _?=$INSTDIR" done64: ${EndIf} @@ -964,15 +1078,19 @@ Section "Application" SecApplication SetShellVarContext all SetOutPath "$INSTDIR" +!insertmacro CockLog "section: InstDir=$INSTDIR PortableMode=$PortableMode ReinstallMode=$ReinstallMode" + ; No application instance may be running while any file below is deleted or ; replaced: both upgrade blocks start by removing the old executables and ; runtime DLLs, and NSIS silently skips whatever is locked, which is how an -; install ends up with a mix of old and new Qt DLLs next to the new executable -; ("The procedure entry point X could not be located in the dynamic link -; library ...Qt6Network.dll"). So the check has to come before the first -; Delete/RMDir and not after it - an interactive install into an existing -; portable directory with cockatrice.exe still running would otherwise delete -; the old files under its feet. +; install ends up with a mix of old and new runtimes next to the new executable. +; The next start then fails with "The procedure entry point X could not be +; located in the dynamic link library ...Qt6Network.dll" for the Qt ones, or +; "abseil_dll.dll is either not designed to run on Windows or it contains an +; error" for the protobuf ones. So the check has to come before the first +; Delete/RMDir and not after it: an interactive install into an existing portable +; directory with cockatrice.exe still running would otherwise delete the old +; files under its feet. ; ; It is skipped on the /R path: .onInit already ran it before ; AutoUninstallIfNeeded removed the previous installation, so by the time this @@ -987,6 +1105,8 @@ ${EndIf} ${If} $PortableMode = 1 ${AndIf} ${FileExists} "$INSTDIR\portable.dat" ; upgrade portable mode + !insertmacro CockLog "section: portable upgrade purge" + !insertmacro CockLogListing "before-portable-purge" "*.dll" RMDir /r "$INSTDIR\plugins" RMDir /r "$INSTDIR\sounds" RMDir /r "$INSTDIR\themes" @@ -995,24 +1115,24 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" Delete "$INSTDIR\cockatrice.exe" Delete "$INSTDIR\oracle.exe" Delete "$INSTDIR\servatrice.exe" - Delete "$INSTDIR\Qt*.dll" - Delete "$INSTDIR\libmysql.dll" - Delete "$INSTDIR\icu*.dll" - Delete "$INSTDIR\libcrypto*.dll" - Delete "$INSTDIR\libssl*.dll" - Delete "$INSTDIR\libeay32.dll" - Delete "$INSTDIR\ssleay32.dll" + ; Every top-level DLL, not a list of expected ones. The payload is written + ; after this block, so a DLL the new build still needs is put straight back; + ; 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" Delete "$INSTDIR\qt.conf" Delete "$INSTDIR\qdebug.txt" Delete "$INSTDIR\servatrice.sql" Delete "$INSTDIR\servatrice.ini.example" - Delete "$INSTDIR\zlib*.dll" RMDir "$INSTDIR" + !insertmacro CockLogListing "after-portable-purge" "*.dll" ${EndIf} ; Remove any runtime DLLs left over from older versions, so a mismatched -; Qt/OpenSSL set cannot survive next to the new executables. +; Qt/OpenSSL/protobuf set cannot survive next to the new executables. Call IsExistingCockatriceInstall +!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" ; Remembered because the stale-runtime check below must only ever fire for a ; directory this installer recognised as a Cockatrice install. Scanning for ; leftovers in an unrelated directory (a first-time install into a folder that @@ -1021,48 +1141,48 @@ Call IsExistingCockatriceInstall StrCpy $IsUpgrade $R0 ${If} $PortableMode = 0 ${AndIf} $R0 = 1 + !insertmacro CockLog "section: non-portable upgrade purge" + !insertmacro CockLogListing "before-purge" "*.dll" RMDir /r "$INSTDIR\Plugins" - Delete "$INSTDIR\Qt*.dll" - Delete "$INSTDIR\libcrypto*.dll" - Delete "$INSTDIR\libssl*.dll" - Delete "$INSTDIR\zlib*.dll" - Delete "$INSTDIR\libmysql.dll" - Delete "$INSTDIR\icu*.dll" - Delete "$INSTDIR\libeay32.dll" - Delete "$INSTDIR\ssleay32.dll" + Delete "$INSTDIR\*.dll" + !insertmacro CockLogListing "after-purge" "*.dll" + !insertmacro CockLogListing "after-plugins" "Plugins\*.*" ${EndIf} -; Whatever was locked during the purge - by an instance that started while the +; Whatever was locked during the purge (by an instance that started while the ; installer was running, or because the running instance check could not be -; performed at all - is still on disk, and the new executables are about to be +; performed at all) is still on disk, and the new executables are about to be ; written next to it. That mix is exactly what makes the next start fail with ; "The procedure entry point X could not be located in the dynamic link library -; ...Qt6QuickWidgets.dll" or "The code execution cannot proceed because -; Qt6Multimedia.dll was not found", 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. +; ...Qt6QuickWidgets.dll", "The code execution cannot proceed because +; Qt6Multimedia.dll was not found", or "abseil_dll.dll is either not designed to +; 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. ${If} $IsUpgrade = 1 Call HasStaleRuntimeFiles + !insertmacro CockLog "section: HasStaleRuntimeFiles=$R0" ${Else} StrCpy $R0 0 ${EndIf} ${If} $R0 = 1 + !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." ; The in-app updater runs this installer silent and detached and never looks ; at its exit code, so the user has to be told what happened. A /S install - ; without /R is unattended - a package manager, for instance - and a message + ; 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. - ${IfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION \ - "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." - ${EndIf} - SetErrorLevel 1 - Abort + ; and the exit code. AbortInstall owns that decision. + 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." + Call AbortInstall ${EndIf} @CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@ +!insertmacro CockLog "section: writing payload to $INSTDIR" @CPACK_NSIS_FULL_INSTALL@ +!insertmacro CockLog "section: payload written" +!insertmacro CockLogListing "final" "Qt6*.dll" ${If} $PortableMode = 0 WriteUninstaller "$INSTDIR\uninstall.exe" @@ -1126,6 +1246,7 @@ ${If} $ReinstallMode = 1 Exec '"$INSTDIR\cockatrice.exe"' ${EndIf} +!insertmacro CockLog "section: application install complete" SectionEnd Section "Start menu item" SecStartMenu diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 14258fad2..32b81cf61 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -691,7 +691,7 @@ if(WIN32) # import table, and the glob above only sees DLLs that were already copied next # to the executable. Qt6::Concurrent is linked but never imported - QtConcurrent::run # and QFutureWatcher are header-only in Qt 6 - so neither step ships - # Qt6Concurrent.dll, even though required-qt-runtime.txt lists it and the + # Qt6Concurrent.dll, even though required-runtime.txt lists it and the # Windows package check then fails on a package that is fine to run. # Qt6::ShaderTools is skipped for the same reason. # From b8e8f2c738462218927533f41717907d04356983 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:36:26 +0200 Subject: [PATCH 002/247] [PrintingSelector] Fix crash setting all printings to preferred (#7467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPreferredPrinting returns a null PrintingInfo when a deck card has no database entry or printings. SetProviderIdToPreferred dereferenced getSet() unconditionally, crashing on custom or unresolved cards. Guard against a null preferred set and leave the card's cleared printing data as is. Co-authored-by: Lukas Brübach --- .../interface/deck_loader/card_node_function.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cockatrice/src/interface/deck_loader/card_node_function.cpp b/cockatrice/src/interface/deck_loader/card_node_function.cpp index 0934b34da..8d59a6c15 100644 --- a/cockatrice/src/interface/deck_loader/card_node_function.cpp +++ b/cockatrice/src/interface/deck_loader/card_node_function.cpp @@ -15,13 +15,14 @@ void CardNodeFunction::SetProviderIdToPreferred::operator()(const InnerDecklistN { Q_UNUSED(node); PrintingInfo preferredPrinting = CardDatabaseManager::query()->getPreferredPrinting(card->getName()); - QString providerId = preferredPrinting.getUuid(); - QString setShortName = preferredPrinting.getSet()->getShortName(); - QString collectorNumber = preferredPrinting.getProperty("num"); + CardSetPtr preferredSet = preferredPrinting.getSet(); + if (!preferredSet) { + return; + } - card->setCardProviderId(providerId); - card->setCardCollectorNumber(collectorNumber); - card->setCardSetShortName(setShortName); + card->setCardProviderId(preferredPrinting.getUuid()); + card->setCardCollectorNumber(preferredPrinting.getProperty("num")); + card->setCardSetShortName(preferredSet->getShortName()); } void CardNodeFunction::ClearPrintingData::operator()(const InnerDecklistNode *node, DecklistCardNode *card) const From be2c16eca523b9517db860628f7a894e0680945f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:45:42 +0200 Subject: [PATCH 003/247] [UI] Fix auto-completion acceptance on Space/Tab/Enter (#7466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [UI] Fix auto-completion acceptance on Space/Tab/Enter * [UI] Accept current completion when no item selected in popup * [UI] Also accept completion on focus out when none selected * [UI] Keep focus in chat on Tab completion acceptance * [UI] Prevent Tab from moving focus when no completion popup * [UI] Refocus on Tab when completing * [UI] Let focusOut handle Tab completion acceptance * [UI] Restore Tab behavior to match original (handle in focusOut) * [UI] Defer refocus on Tab to avoid focus chain issues * [UI] Auto-select first completion match in popup * [UI] Auto-select first match when completer popup resets * Revert "[UI] Auto-select first match when completer popup resets" This reverts commit c7a3de3595a565cbcb35610ebe3e4b36322e0919. * Revert "[UI] Auto-select first completion match in popup" This reverts commit ae0dfb862263e8e39339103b1fd87535d2437c9a. * [UI] Ensure completion selection updates on model changes * Revert "[UI] Ensure completion selection updates on model changes" This reverts commit 686bbef5b2a7d87bd1ecd04c4b7e2a23143188cd. * [UI] Highlight the selected completion in chat popups The card completer's above-variant moved the current row with NoUpdate, so the delegate never painted the selection border, and it used Select which accumulated highlights while navigating. Use ClearAndSelect so only the current row is highlighted and the old one is cleared. The mention popup opened with no current row at all, so nothing was highlighted and there was no visual hint of what Enter would insert. Select the row currentCompletion() uses, and install a delegate that mirrors the active highlight into the inactive group so the selection stays visible when the transient popup is not the active window. --------- Co-authored-by: Lukas Brübach --- .../widgets/utility/card_completer_styler.cpp | 5 +- .../widgets/utility/completer_utils.cpp | 52 +++++++++++ .../widgets/utility/line_edit_completer.cpp | 90 +++++++++++++++---- 3 files changed, 128 insertions(+), 19 deletions(-) diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index fef1dcbf7..1ddcc4200 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -150,7 +150,8 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event) if (newRow != currentRow) { const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn()); - popup->selectionModel()->setCurrentIndex(newIndex, QItemSelectionModel::NoUpdate); + popup->selectionModel()->setCurrentIndex(newIndex, + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); } return true; @@ -180,7 +181,7 @@ void CardCompleterStyler::ensureClosestSelected() if (currentRow != closestRow) { popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()), - QItemSelectionModel::NoUpdate); + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); } } diff --git a/cockatrice/src/interface/widgets/utility/completer_utils.cpp b/cockatrice/src/interface/widgets/utility/completer_utils.cpp index 53db58b70..f1cd9c6a7 100644 --- a/cockatrice/src/interface/widgets/utility/completer_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/completer_utils.cpp @@ -3,12 +3,19 @@ #include "../../../client/settings/cache_settings.h" #include "card_completer_styler.h" +#include #include #include +#include #include +#include +#include #include #include #include +#include +#include +#include #include #include #include @@ -17,6 +24,45 @@ namespace { +/** + * @brief Paints the mention popup's current row with a visible highlight. + * + * A completer popup is a transient window that is usually not the application's + * active window, so the style paints a selected row using the palette's + * Inactive highlight group. Most themes set that colour close to the popup + * background, which hides the current completion entirely. Mirror the active + * highlight into the inactive group so the selection stays visible. + */ +class MentionCompleterDelegate : public QStyledItemDelegate +{ +public: + explicit MentionCompleterDelegate(QAbstractItemView *view) : QStyledItemDelegate(view), view(view) + { + } + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + QStyleOptionViewItem opt = option; + opt.showDecorationSelected = true; + + if (view->currentIndex() == index) { + opt.state |= QStyle::State_HasFocus; + } + + if (opt.state & QStyle::State_Selected) { + opt.palette.setColor(QPalette::Inactive, QPalette::Highlight, + opt.palette.color(QPalette::Active, QPalette::Highlight)); + opt.palette.setColor(QPalette::Inactive, QPalette::HighlightedText, + opt.palette.color(QPalette::Active, QPalette::HighlightedText)); + } + + QStyledItemDelegate::paint(painter, opt, index); + } + +private: + QAbstractItemView *view; +}; + void applyCardSearchLanguage(CardSearchModel *searchModel) { const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); @@ -69,5 +115,11 @@ QCompleter *createMentionCompleter(QStringListModel *model, QObject *parent) completer->setCaseSensitivity(Qt::CaseInsensitive); completer->setMaxVisibleItems(5); completer->setFilterMode(Qt::MatchStartsWith); + + // Force the popup to be created so the highlight delegate can be installed; + // otherwise QCompleter's own delegate hides the selection on most themes. + QAbstractItemView *popup = completer->popup(); + popup->setItemDelegate(new MentionCompleterDelegate(popup)); + return completer; } diff --git a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp index 38e3e4350..056887f1a 100644 --- a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp +++ b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp @@ -1,9 +1,11 @@ #include "line_edit_completer.h" +#include #include #include #include #include +#include #include #include #include @@ -48,37 +50,80 @@ void LineEditCompleter::focusOutEvent(QFocusEvent *e) { LineEditUnfocusable::focusOutEvent(e); - // Only commit the highlighted completion when focus moves away via Tab. + // Commit completion when focus moves away via Tab if popup is visible. // Other focus losses (e.g. the unfocus shortcut / Escape) must simply close // the popup without inserting anything. - if (e->reason() != Qt::TabFocusReason) { - hideCompleterPopups(); + bool popupWasVisible = false; + for (auto &info : completers) { + if (info.completer->popup()->isVisible()) { + popupWasVisible = true; + const QModelIndex currentIndex = info.completer->popup()->currentIndex(); + QString completionText; + if (currentIndex.isValid()) { + completionText = currentIndex.data().toString(); + } else { + completionText = info.completer->currentCompletion(); + } + if (!completionText.isEmpty()) { + insertCompletion(info.completer, completionText); + } + } + } + hideCompleterPopups(); + + if (popupWasVisible && e->reason() == Qt::TabFocusReason) { + // Refocus the edit so Tab doesn't move focus away + // Use a queued connection to ensure focus is restored after the focus change completes + QMetaObject::invokeMethod(this, "setFocus", Qt::QueuedConnection); + e->accept(); return; } - for (auto &info : completers) { - if (!info.completer->popup()->isVisible()) { - continue; - } - - const QModelIndex currentIndex = info.completer->popup()->currentIndex(); - if (currentIndex.isValid()) { - insertCompletion(info.completer, currentIndex.data().toString()); - } + if (popupWasVisible) { + return; } - - hideCompleterPopups(); } void LineEditCompleter::keyPressEvent(QKeyEvent *event) { - LineEditUnfocusable::keyPressEvent(event); - if (event->key() == Qt::Key_Escape) { - hideCompleterPopups(); + if (hasVisibleCompleterPopup()) { + event->ignore(); + hideCompleterPopups(); + return; + } + LineEditUnfocusable::keyPressEvent(event); return; } + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Space) { + for (auto &info : completers) { + if (info.completer->popup()->isVisible()) { + event->ignore(); + const QModelIndex currentIndex = info.completer->popup()->currentIndex(); + QString completionText; + if (currentIndex.isValid()) { + completionText = currentIndex.data().toString(); + } else { + completionText = info.completer->currentCompletion(); + } + if (!completionText.isEmpty()) { + insertCompletion(info.completer, completionText); + } + hideCompleterPopups(); + return; + } + } + } + + if (event->key() == Qt::Key_Tab) { + // Let Tab proceed normally; focusOutEvent will handle completion if popup is visible + LineEditUnfocusable::keyPressEvent(event); + return; + } + + LineEditUnfocusable::keyPressEvent(event); + QString textValue = text(); int cursorPos = cursorPosition(); @@ -132,6 +177,17 @@ void LineEditCompleter::keyPressEvent(QKeyEvent *event) } active->completer->complete(); + + // The popup opens without a current row, so nothing is highlighted and there + // is no visual indication of what Enter will insert. Select the row that + // currentCompletion() would use so the current mention stays visible. + auto *completionModel = active->completer->completionModel(); + const int currentRow = active->completer->currentRow(); + if (currentRow >= 0 && currentRow < completionModel->rowCount()) { + active->completer->popup()->selectionModel()->setCurrentIndex( + completionModel->index(currentRow, active->completer->completionColumn()), + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } } void LineEditCompleter::insertCompletion(const QString &completion) From efc7e7a07940b747c9e9d948ede7ef491fe815a2 Mon Sep 17 00:00:00 2001 From: ebbit1q Date: Sat, 10 Oct 2026 17:15:10 +0200 Subject: [PATCH 004/247] fix the desktop file, removing the invalid key (#7406) * fix the desktop file, removing the invalid key * Update cockatrice.desktop --------- Co-authored-by: BruebachL <44814898+BruebachL@users.noreply.github.com> --- cockatrice/cockatrice.desktop | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cockatrice/cockatrice.desktop b/cockatrice/cockatrice.desktop index 4b15fa9c1..70db9d472 100644 --- a/cockatrice/cockatrice.desktop +++ b/cockatrice/cockatrice.desktop @@ -6,5 +6,4 @@ Name=Cockatrice Exec=cockatrice %U Icon=cockatrice Categories=Game;CardGame; -MimeType=application/x-cockatrice; -X-Scheme-Handler/cockatrice=true +MimeType=application/x-cockatrice;x-scheme-handler/cockatrice; From 7fa6234eeee5e9f9c7fdeb3160af981e8d17c796 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 10 Oct 2026 23:48:37 +0200 Subject: [PATCH 005/247] [Docs] Add app logo to main page + cleanup (#7471) * Add app logo to main page * rename pages folder * Add link + cleanup * Cleanup file extension patterns * Update Doxygen README.md --- .github/workflows/documentation-build.yml | 12 +- Doxyfile | 16 +- doc/doxygen/README.md | 7 +- doc/doxygen/images/cockatrice_logo.svg | 232 ++++++++++++++++++ .../classic_database_display.png | Bin .../classic_database_display_add_buttons.png | Bin .../{ => screenshots}/classic_deck_editor.png | Bin .../{ => screenshots}/deck_dock_deck_list.png | Bin .../deck_dock_deck_list_buttons.png | Bin .../deck_dock_deck_list_group_by.png | Bin .../deckeditordeckdockwidget.png | Bin .../{ => screenshots}/printing_selector.png | Bin .../printing_selector_disable.png | Bin .../printing_selector_enable.png | Bin .../printing_selector_navigation.png | Bin .../printing_selector_options.png | Bin .../printing_selector_pre_providerid.png | Bin .../release_channel_beta.png | Bin .../{ => screenshots}/vde_deck_analytics.png | Bin .../vde_flat_layout_color_grouped.png | Bin .../vde_flat_layout_type_grouped.png | Bin .../vde_overlap_layout_type_grouped.png | Bin .../{ => screenshots}/vde_sample_hand.png | Bin .../card_database_schema_and_parsing.md | 0 .../displaying_cards.md | 0 .../developer_documentation/index.md | 0 .../loading_card_pictures.md | 0 .../developer_documentation/logging.md | 0 .../network/client/game_event_handler.md | 0 .../network/client/index.md | 0 .../network/client/player_event_handler.md | 0 .../developer_documentation/network/index.md | 0 .../network/protocol/index.md | 0 .../protocol/protocol_command_container.md | 0 .../network/protocol/protocol_game_command.md | 0 .../network/protocol/protocol_overview.md | 0 .../network/protocol/protocol_response.md | 0 .../protocol/protocol_server_message.md | 0 .../developer_documentation/primer_cards.md | 0 .../querying_the_card_database.md | 0 doc/doxygen/{extra-pages => pages}/index.md | 4 + .../card_pictures/custom_card_pictures.md | 0 .../deck_management/creating_decks.md | 0 .../deck_management/editing_decks.md | 0 .../deck_management/editing_decks_classic.md | 0 .../editing_decks_printings.md | 0 .../deck_management/editing_decks_visual.md | 0 .../deck_management/exporting_decks.md | 0 .../deck_management/importing_decks.md | 0 .../user_documentation/index.md | 0 .../user_documentation/settings/beta.md | 0 .../settings/settings_folder.md | 0 .../troubleshooting/enabling_debug_logs.md | 0 .../troubleshooting/fixing_card_pictures.md | 0 54 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 doc/doxygen/images/cockatrice_logo.svg rename doc/doxygen/images/{ => screenshots}/classic_database_display.png (100%) rename doc/doxygen/images/{ => screenshots}/classic_database_display_add_buttons.png (100%) rename doc/doxygen/images/{ => screenshots}/classic_deck_editor.png (100%) rename doc/doxygen/images/{ => screenshots}/deck_dock_deck_list.png (100%) rename doc/doxygen/images/{ => screenshots}/deck_dock_deck_list_buttons.png (100%) rename doc/doxygen/images/{ => screenshots}/deck_dock_deck_list_group_by.png (100%) rename doc/doxygen/images/{ => screenshots}/deckeditordeckdockwidget.png (100%) rename doc/doxygen/images/{ => screenshots}/printing_selector.png (100%) rename doc/doxygen/images/{ => screenshots}/printing_selector_disable.png (100%) rename doc/doxygen/images/{ => screenshots}/printing_selector_enable.png (100%) rename doc/doxygen/images/{ => screenshots}/printing_selector_navigation.png (100%) rename doc/doxygen/images/{ => screenshots}/printing_selector_options.png (100%) rename doc/doxygen/images/{ => screenshots}/printing_selector_pre_providerid.png (100%) rename doc/doxygen/images/{ => screenshots}/release_channel_beta.png (100%) rename doc/doxygen/images/{ => screenshots}/vde_deck_analytics.png (100%) rename doc/doxygen/images/{ => screenshots}/vde_flat_layout_color_grouped.png (100%) rename doc/doxygen/images/{ => screenshots}/vde_flat_layout_type_grouped.png (100%) rename doc/doxygen/images/{ => screenshots}/vde_overlap_layout_type_grouped.png (100%) rename doc/doxygen/images/{ => screenshots}/vde_sample_hand.png (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/card_database_schema_and_parsing.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/displaying_cards.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/index.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/loading_card_pictures.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/logging.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/client/game_event_handler.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/client/index.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/client/player_event_handler.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/index.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/protocol/index.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/protocol/protocol_command_container.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/protocol/protocol_game_command.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/protocol/protocol_overview.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/protocol/protocol_response.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/network/protocol/protocol_server_message.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/primer_cards.md (100%) rename doc/doxygen/{extra-pages => pages}/developer_documentation/querying_the_card_database.md (100%) rename doc/doxygen/{extra-pages => pages}/index.md (77%) rename doc/doxygen/{extra-pages => pages}/user_documentation/card_pictures/custom_card_pictures.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/deck_management/creating_decks.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/deck_management/editing_decks.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/deck_management/editing_decks_classic.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/deck_management/editing_decks_printings.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/deck_management/editing_decks_visual.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/deck_management/exporting_decks.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/deck_management/importing_decks.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/index.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/settings/beta.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/settings/settings_folder.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/troubleshooting/enabling_debug_logs.md (100%) rename doc/doxygen/{extra-pages => pages}/user_documentation/troubleshooting/fixing_card_pictures.md (100%) diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index f13b964cd..91ee14e3e 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -28,12 +28,13 @@ jobs: runs-on: ubuntu-latest steps: - - name: "Checkout code" + - name: "Checkout" uses: actions/checkout@v7 with: submodules: recursive - name: "Install Graphviz" + shell: bash run: | sudo apt-get install -y graphviz dot -V @@ -44,6 +45,7 @@ jobs: version: "1.18.0" - name: "Update Doxygen Configuration" + shell: bash run: | git diff Doxyfile doxygen -u Doxyfile @@ -58,9 +60,10 @@ jobs: - name: "Generate Documentation" if: always() + shell: bash run: doxygen Doxyfile - - name: "Deploy to cockatrice.github.io" + - name: "Deploy to cockatrice.github.io/docs" if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' uses: peaceiris/actions-gh-pages@v4 with: @@ -69,3 +72,8 @@ jobs: external_repository: Cockatrice/cockatrice.github.io publish_branch: master publish_dir: ./docs/html + + - name: Link to Documentation Page + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + shell: bash + run: echo "::notice title=New documentation published::Shortly available at https://cockatrice.github.io/docs/" diff --git a/Doxyfile b/Doxyfile index b722f779f..c12ff4a8a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -1066,25 +1066,11 @@ INPUT_FILE_ENCODING = # provided as Doxygen C comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, # *.f18, *.f, *.for, *.vhd, *.vhdl, *.ucf, *.qsf and *.ice. -FILE_PATTERNS = *.cc \ - *.cxx \ - *.cxxm \ - *.cpp \ +FILE_PATTERNS = *.cpp \ *.cppm \ - *.ccm \ - *.c++ \ - *.c++m \ - *.ii \ - *.ixx \ *.ipp \ - *.i++ \ *.inl \ *.h \ - *.hh \ - *.hxx \ - *.hpp \ - *.h++ \ - *.markdown \ *.md \ *.dox \ *.proto diff --git a/doc/doxygen/README.md b/doc/doxygen/README.md index 0e6cee7f4..230422303 100644 --- a/doc/doxygen/README.md +++ b/doc/doxygen/README.md @@ -1,13 +1,16 @@ ## Building Doxygen Documentation We use [Doxygen](https://www.doxygen.nl) for our code documentation. -After installing the tool, you can build a HTML webpage into `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): +After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): ```bash doxygen Doxyfile ``` -Open the `index.html` file in the `/docs/html/` folder to view and use it. +Open the `index.html` file in the `/docs/html/` folder to browse the result locally. There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs) +Doxygen's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual). + +
## Doxygen Documentation Theme diff --git a/doc/doxygen/images/cockatrice_logo.svg b/doc/doxygen/images/cockatrice_logo.svg new file mode 100644 index 000000000..89ba62dcf --- /dev/null +++ b/doc/doxygen/images/cockatrice_logo.svg @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/doxygen/images/classic_database_display.png b/doc/doxygen/images/screenshots/classic_database_display.png similarity index 100% rename from doc/doxygen/images/classic_database_display.png rename to doc/doxygen/images/screenshots/classic_database_display.png diff --git a/doc/doxygen/images/classic_database_display_add_buttons.png b/doc/doxygen/images/screenshots/classic_database_display_add_buttons.png similarity index 100% rename from doc/doxygen/images/classic_database_display_add_buttons.png rename to doc/doxygen/images/screenshots/classic_database_display_add_buttons.png diff --git a/doc/doxygen/images/classic_deck_editor.png b/doc/doxygen/images/screenshots/classic_deck_editor.png similarity index 100% rename from doc/doxygen/images/classic_deck_editor.png rename to doc/doxygen/images/screenshots/classic_deck_editor.png diff --git a/doc/doxygen/images/deck_dock_deck_list.png b/doc/doxygen/images/screenshots/deck_dock_deck_list.png similarity index 100% rename from doc/doxygen/images/deck_dock_deck_list.png rename to doc/doxygen/images/screenshots/deck_dock_deck_list.png diff --git a/doc/doxygen/images/deck_dock_deck_list_buttons.png b/doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png similarity index 100% rename from doc/doxygen/images/deck_dock_deck_list_buttons.png rename to doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png diff --git a/doc/doxygen/images/deck_dock_deck_list_group_by.png b/doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png similarity index 100% rename from doc/doxygen/images/deck_dock_deck_list_group_by.png rename to doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png diff --git a/doc/doxygen/images/deckeditordeckdockwidget.png b/doc/doxygen/images/screenshots/deckeditordeckdockwidget.png similarity index 100% rename from doc/doxygen/images/deckeditordeckdockwidget.png rename to doc/doxygen/images/screenshots/deckeditordeckdockwidget.png diff --git a/doc/doxygen/images/printing_selector.png b/doc/doxygen/images/screenshots/printing_selector.png similarity index 100% rename from doc/doxygen/images/printing_selector.png rename to doc/doxygen/images/screenshots/printing_selector.png diff --git a/doc/doxygen/images/printing_selector_disable.png b/doc/doxygen/images/screenshots/printing_selector_disable.png similarity index 100% rename from doc/doxygen/images/printing_selector_disable.png rename to doc/doxygen/images/screenshots/printing_selector_disable.png diff --git a/doc/doxygen/images/printing_selector_enable.png b/doc/doxygen/images/screenshots/printing_selector_enable.png similarity index 100% rename from doc/doxygen/images/printing_selector_enable.png rename to doc/doxygen/images/screenshots/printing_selector_enable.png diff --git a/doc/doxygen/images/printing_selector_navigation.png b/doc/doxygen/images/screenshots/printing_selector_navigation.png similarity index 100% rename from doc/doxygen/images/printing_selector_navigation.png rename to doc/doxygen/images/screenshots/printing_selector_navigation.png diff --git a/doc/doxygen/images/printing_selector_options.png b/doc/doxygen/images/screenshots/printing_selector_options.png similarity index 100% rename from doc/doxygen/images/printing_selector_options.png rename to doc/doxygen/images/screenshots/printing_selector_options.png diff --git a/doc/doxygen/images/printing_selector_pre_providerid.png b/doc/doxygen/images/screenshots/printing_selector_pre_providerid.png similarity index 100% rename from doc/doxygen/images/printing_selector_pre_providerid.png rename to doc/doxygen/images/screenshots/printing_selector_pre_providerid.png diff --git a/doc/doxygen/images/release_channel_beta.png b/doc/doxygen/images/screenshots/release_channel_beta.png similarity index 100% rename from doc/doxygen/images/release_channel_beta.png rename to doc/doxygen/images/screenshots/release_channel_beta.png diff --git a/doc/doxygen/images/vde_deck_analytics.png b/doc/doxygen/images/screenshots/vde_deck_analytics.png similarity index 100% rename from doc/doxygen/images/vde_deck_analytics.png rename to doc/doxygen/images/screenshots/vde_deck_analytics.png diff --git a/doc/doxygen/images/vde_flat_layout_color_grouped.png b/doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png similarity index 100% rename from doc/doxygen/images/vde_flat_layout_color_grouped.png rename to doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png diff --git a/doc/doxygen/images/vde_flat_layout_type_grouped.png b/doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/vde_flat_layout_type_grouped.png rename to doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png diff --git a/doc/doxygen/images/vde_overlap_layout_type_grouped.png b/doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/vde_overlap_layout_type_grouped.png rename to doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png diff --git a/doc/doxygen/images/vde_sample_hand.png b/doc/doxygen/images/screenshots/vde_sample_hand.png similarity index 100% rename from doc/doxygen/images/vde_sample_hand.png rename to doc/doxygen/images/screenshots/vde_sample_hand.png diff --git a/doc/doxygen/extra-pages/developer_documentation/card_database_schema_and_parsing.md b/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/card_database_schema_and_parsing.md rename to doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md diff --git a/doc/doxygen/extra-pages/developer_documentation/displaying_cards.md b/doc/doxygen/pages/developer_documentation/displaying_cards.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/displaying_cards.md rename to doc/doxygen/pages/developer_documentation/displaying_cards.md diff --git a/doc/doxygen/extra-pages/developer_documentation/index.md b/doc/doxygen/pages/developer_documentation/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/index.md rename to doc/doxygen/pages/developer_documentation/index.md diff --git a/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md b/doc/doxygen/pages/developer_documentation/loading_card_pictures.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md rename to doc/doxygen/pages/developer_documentation/loading_card_pictures.md diff --git a/doc/doxygen/extra-pages/developer_documentation/logging.md b/doc/doxygen/pages/developer_documentation/logging.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/logging.md rename to doc/doxygen/pages/developer_documentation/logging.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md b/doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md rename to doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/index.md b/doc/doxygen/pages/developer_documentation/network/client/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/client/index.md rename to doc/doxygen/pages/developer_documentation/network/client/index.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md b/doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md rename to doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/index.md b/doc/doxygen/pages/developer_documentation/network/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/index.md rename to doc/doxygen/pages/developer_documentation/network/index.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md b/doc/doxygen/pages/developer_documentation/network/protocol/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md rename to doc/doxygen/pages/developer_documentation/network/protocol/index.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md diff --git a/doc/doxygen/extra-pages/developer_documentation/primer_cards.md b/doc/doxygen/pages/developer_documentation/primer_cards.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/primer_cards.md rename to doc/doxygen/pages/developer_documentation/primer_cards.md diff --git a/doc/doxygen/extra-pages/developer_documentation/querying_the_card_database.md b/doc/doxygen/pages/developer_documentation/querying_the_card_database.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/querying_the_card_database.md rename to doc/doxygen/pages/developer_documentation/querying_the_card_database.md diff --git a/doc/doxygen/extra-pages/index.md b/doc/doxygen/pages/index.md similarity index 77% rename from doc/doxygen/extra-pages/index.md rename to doc/doxygen/pages/index.md index b3363d3f4..9dafe1882 100644 --- a/doc/doxygen/extra-pages/index.md +++ b/doc/doxygen/pages/index.md @@ -1,5 +1,9 @@ @mainpage Documentation +
+ +
+ Welcome to the Cockatrice code documentation. - @subpage user_reference diff --git a/doc/doxygen/extra-pages/user_documentation/card_pictures/custom_card_pictures.md b/doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/card_pictures/custom_card_pictures.md rename to doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/creating_decks.md b/doc/doxygen/pages/user_documentation/deck_management/creating_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/creating_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/creating_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_classic.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_classic.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_printings.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_printings.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_visual.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_visual.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/exporting_decks.md b/doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/exporting_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/importing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/importing_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/importing_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/index.md b/doc/doxygen/pages/user_documentation/index.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/index.md rename to doc/doxygen/pages/user_documentation/index.md diff --git a/doc/doxygen/extra-pages/user_documentation/settings/beta.md b/doc/doxygen/pages/user_documentation/settings/beta.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/settings/beta.md rename to doc/doxygen/pages/user_documentation/settings/beta.md diff --git a/doc/doxygen/extra-pages/user_documentation/settings/settings_folder.md b/doc/doxygen/pages/user_documentation/settings/settings_folder.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/settings/settings_folder.md rename to doc/doxygen/pages/user_documentation/settings/settings_folder.md diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md b/doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md rename to doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md b/doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md rename to doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md From 8822ade49d03fa1bac1e3ae38693fe1818821329 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:50:09 +0200 Subject: [PATCH 006/247] [Tests] Register each test case with CTest instead of each test binary (#7399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Tests] Register each test case with CTest instead of each test binary Registering a test cost four edits across a dozen CMakeLists files: add_test, add_executable, a conditional add_dependencies on gtest, and target_link_libraries. The conditional branch was the trap -- it is only needed when GTest was fetched and add_subdirectory'd, and omitting it yields undefined gtest symbols at link time -- so it was easy to leave out and impossible to notice until the link failed. Fold all four steps into cockatrice_add_test() and migrate every test directory onto it, with a cockatrice_add_manual_test() wrapper for the three benchmarks that are built but deliberately kept out of ctest. The calls now read as a declaration of what a test is rather than a transcript of how to register one. While rewriting the registrations, replace add_test(NAME x COMMAND x) with gtest_discover_tests(). Naming the binary as a single CTest entry meant a CI failure reported only which binary failed, and ctest -R Suite.Case could not select an individual case. Discovery raises the suite from 35 opaque entries to 469 real ones: ctest -R ThemeMigrationTest.IsIdempotent now runs exactly that case, and ctest -L server / -LE bench work off labels applied at registration. Give every test a 60s TIMEOUT. CTest's implicit default is 1500s, so one hung socket wait would stall CI for 25 minutes, and single_instance_manager_test already does real QLocalSocket waits. Dropping per-case granularity costs wall-clock time, since the suite becomes process-spawn bound rather than CPU bound: serial ctest went from 3.8s to 30.8s. Run it with -j in CI to recover it -- 7.6s at -j4, and 3.5s at -j8, both at or below the old serial time. The binaries are parallel-safe: the only test claiming a fixed resource scopes its local socket name to its own pid, so concurrent copies cannot collide. Also delete dummy_test, which asserted 1 == 1 and only proved the harness was wired up -- something the other 468 cases already establish. No test is added, removed, or changed by this commit; the 469 cases are the same ones the 35 binaries contained. * [Tests] Make QtTest available to test targets QtTest was not among the modules test targets could link, so the Qt model and signal contracts had no way to be verified from a test: QAbstractItemModelTester and QSignalSpy both live there. Add Test to _TEST_NEEDED in cmake/FindQtRuntime.cmake. That is the single place the union of test-only Qt modules is declared, so this is the only file that needs to change to make the module reachable from every test target. Nothing uses it yet. This lands the capability ahead of the model-layer tests that need it, so those can be added as tests alone rather than as test plus build-system plumbing. * [Tests] Correct the note on the oracle test compression defines add_definitions() populates the directory's COMPILE_DEFINITIONS property, which is consumed at generate time, so it reaches every target in the directory no matter where it is called relative to add_executable(). The previous note claimed the flags stayed confined to the benchmark because the find_package block came after it; oracle_importer_test, created above that block, carries both defines too. Verified harmless rather than a live bug: HAS_ZLIB and HAS_LZMA gate code only in oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, and no test target compiles either. The ordering and the leaked defines are both pre-existing, so this only corrects the explanation. * [Tests] Carry one CTest label per test instead of several gtest_discover_tests() can hand CTest exactly one label. GoogleTestAddTests.cmake accumulates its generated script with `foreach(arg ${ARGN})`, so a label list reaches the CTestTestfile as bare `LABELS ci server`, and CTest reads only the first value of a property and drops the rest without a word. The `ci` label that is appended to every registered test was therefore absent from every test that also asked for `server` or `bench`: `ctest -L ci` reported 400 of 469 cases, and `ctest --print-labels` never listed a second label anywhere in the build. Reduce each test to exactly one of `ci`, `server`, or `bench`, which is all the selections need. The three now partition the suite exactly, 400 + 64 + 5 = 469, and `ctest -LE bench` still means everything but benchmarks. A second label is now a configure-time FATAL_ERROR rather than a line that quietly means something else. Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it composes with the group instead of competing for the single slot. Also raise DISCOVERY_TIMEOUT off GoogleTestAddTests' 5s default. A plain debug binary answers --gtest_list_tests in 0.04s, but that budget is thin once several binaries discover concurrently behind a -j build, and being over it fails at the *link* step with a bare "Process terminated due to timeout" that points nowhere near the cause. * [Tests] Link gtest_main on the fetched-gtest path The downloaded-gtest branch set GTEST_BOTH_LIBRARIES to `gtest` alone, while FindGTest sets it to both halves. Every test target links that list rather than picking an entry point, so on this path nothing provides main() and any test source that does not define its own fails at link with `undefined reference to main` pointing at crt1.o, naming neither gtest nor the line responsible. That stayed latent while every test carried its own four-line main(). A test that omits it now fails, and the error points at the C runtime rather than at gtest, which is a long way from the cause. Restoring both halves makes the two paths agree and drops the boilerplate requirement on new test sources. The 18 sources that still define main() keep linking: gtest_main.cc defines nothing but main, so the linker leaves its object in the archive unused rather than reporting a duplicate. * [Build] Run the test suite under ASan and UBSan (#7400) * [Build] Add an ENABLE_SANITIZERS option for ASan and UBSan Adds -DENABLE_SANITIZERS, off by default, building with AddressSanitizer and UndefinedBehaviorSanitizer. The bulk of the work is in the three ways the obvious implementation silently does the wrong thing. Probe the flags on the link step, not just the compile step. check_cxx_compiler_flag() only compiles, so every sanitizer flag comes back unsupported, because linking an ASan object without -fsanitize=address leaves __asan_init undefined. The result is not a build failure but a build that appears instrumented and is not, which is worse. Use check_cxx_source_compiles() with CMAKE_REQUIRED_LINK_OPTIONS instead, and treat an unsupported sanitizer pair as fatal since the cosmetic flags are allowed to degrade. Opt vendored sources out per source, through a cockatrice_disable_sanitizers_for() helper rather than a list of set_source_files_properties() calls at the root. The helper has to exist because CMake resolves a source-file property against the directory scope its target was declared in: setting the property from the root with absolute paths is a no-op, silently, which is why the call sites sit next to the add_library and add_executable that pull the vendored code in. SFMT, minizip, the LZMA SDK and Qxt SMTP are excluded. oracle/src/qt-json is in the history but not in the tree, removed in #7214. Also mark those sources SKIP_PRECOMPILE_HEADERS. The PCH is built with the sanitizer flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with -fno-sanitize=all does not and GCC then refuses the PCH, which is a fatal -Werror=invalid-pch in the servatrice and oracle builds. -fno-sanitize=vptr is not optional. Qt constructs QObject subclasses into raw storage and writes the vptr afterwards, so the vptr check reports on essentially every QObject instantiation, and it has no value here because nothing outside moc-generated code inherits QObject. Verified on the resulting build: our own translation units carry ASan and UBSan calls, SFMT.o carries neither, and the full suite of 464 cases passes clean under ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 with UBSAN_OPTIONS=halt_on_error=0. The test suite needs two adjustments to make this option usable at all, both of which land here because they are consequences of COCKATRICE_SANITIZERS existing rather than independent changes. DISCOVERY_TIMEOUT rises from 60s to 300s under sanitizers. An instrumented binary takes 2.8s to answer --gtest_list_tests against 0.04s uninstrumented, and discovery runs several binaries at once behind a -j build. One target is excluded via a NO_SANITIZE flag, server_developer_role_test. Reaching google::protobuf::TextFormat::Printer's destructor aborts on an absl assertion inside system protobuf 33.4 whenever the consumer translation unit is built without NDEBUG, which a Debug sanitizer build is. This reproduces with no Cockatrice code at all, just "TextFormat::Printer p; p.PrintToString(msg, &out);" built with -fsanitize=address, so it is upstream rather than ours. It is a flag and not a fourth label because "skip this in the sanitizer leg" has to compose with a test's group rather than compete for the single label slot, and because a flag excludes by not registering the cases at all, which cannot rot the way a label silently can. The target still builds and still runs on the legs without sanitizers. * [CI] Run the suite once under ASan and UBSan Adds --sanitize to .ci/compile.sh, mirroring --test, and turns it on for one entry of the existing Linux Docker matrix: Ubuntu 26.04, which already had a test-enabled leg. That reuses the image, the ccache layer and the existing step shape, so the CI diff is a matrix key plus the argument plumbing. Debian 13 and Fedora 44 keep covering the uninstrumented suite, so nothing is lost by instrumenting one leg. Sanitizers stay off the release and package legs, which build with --release for packaging: instrumenting those would change the shipped binaries' optimization profile and build time for no benefit. macOS and Windows stay uninstrumented too, since ASan is unreliable against the Qt build there and MSVC has no equivalent without /fsanitize=address. --sanitize does not imply --test, so an instrumented build can be produced locally without paying for ctest. When both are given, ctest runs with detect_leaks=0 because Qt and the sanitizer runtimes both leak at exit by design and LeakSanitizer's default buries real findings under hundreds of process-teardown reports, and with UBSan halt_on_error=0 so one run collects many findings instead of stopping at the first. ASan keeps abort_on_error=1, since a bad access leaves the process unusable and continuing only produces a second, more confusing report from the wreckage. * [Build] Fix sanitizer flag leak into release package and make UBSan fail CI - compile.sh always passes -DENABLE_SANITIZERS explicitly so the release-package reconfigure in the same build dir cannot inherit ON from the sanitized debug step (which shipped libasan/libubsan DEB deps). - Sanitizers.cmake resets COCKATRICE_SANITIZERS to OFF when the option is off (CACHE INTERNAL otherwise sticks forever, keeping NO_SANITIZE skips and the 300s discovery timeout). - UBSan now fails the job: -fno-sanitize-recover=undefined at compile time plus UBSAN_OPTIONS halt_on_error=1 (recover mode exits 0, so the old halt_on_error=0 could never fail). --------- Co-authored-by: Lukas Brübach --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 33 +- .github/workflows/desktop-build.yml | 12 +- CMakeLists.txt | 6 + cmake/FindQtRuntime.cmake | 4 +- cmake/Sanitizers.cmake | 109 ++++ libcockatrice_rng/CMakeLists.txt | 3 + oracle/CMakeLists.txt | 4 + servatrice/CMakeLists.txt | 5 + tests/CMakeLists.txt | 496 +++++++++++------- tests/card_zone_algorithms/CMakeLists.txt | 17 +- tests/carddatabase/CMakeLists.txt | 73 +-- tests/deck_list_model/CMakeLists.txt | 34 +- tests/deck_list_zones/CMakeLists.txt | 11 +- tests/dummy_test.cpp | 20 - tests/loading_from_clipboard/CMakeLists.txt | 19 +- tests/movecard_tests/CMakeLists.txt | 23 +- tests/oracle/CMakeLists.txt | 67 ++- tests/picture_loader_benchmark/CMakeLists.txt | 22 +- tests/settings/CMakeLists.txt | 34 +- tests/theme_migration/CMakeLists.txt | 21 +- 20 files changed, 611 insertions(+), 402 deletions(-) create mode 100644 cmake/Sanitizers.cmake delete mode 100644 tests/dummy_test.cpp diff --git a/.ci/compile.sh b/.ci/compile.sh index 823a31898..de4fac004 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -8,6 +8,7 @@ # --suffix renames package with this suffix, requires arg # --server compiles servatrice # --test runs tests +# --sanitize builds with AddressSanitizer and UndefinedBehaviorSanitizer, gcc/clang only # --debug or --release sets the build type ie CMAKE_BUILD_TYPE # --ccache [] uses ccache and shows stats, optionally provide size # --evict-ccache runs ccache eviction based on given age after build @@ -57,6 +58,10 @@ while [[ $# != 0 ]]; do MAKE_TEST=1 shift ;; + '--sanitize') + MAKE_SANITIZE=1 + shift + ;; '--debug') BUILDTYPE="Debug" shift @@ -147,6 +152,14 @@ fi if [[ $MAKE_TEST ]]; then flags+=("-DTEST=1") fi +if [[ $MAKE_SANITIZE ]]; then + flags+=("-DENABLE_SANITIZERS=ON") +else + # The release-package step re-runs cmake in the same build dir, so an + # ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak + # into the shipped binaries (libasan/libubsan Depends in the DEB). + flags+=("-DENABLE_SANITIZERS=OFF") +fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") # PCH-aware caching is required or ccache refuses to cache any TU that @@ -306,7 +319,25 @@ fi if [[ $MAKE_TEST ]]; then echo "::group::Run tests" - ctest -C "$BUILDTYPE" --output-on-failure + if [[ $MAKE_SANITIZE ]]; then + # detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and + # LeakSanitizer's default is aggressive enough to bury our real findings under hundreds + # of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for + # and they stay fully on. + # abort_on_error=1: a bad access leaves the process in an unusable state, so continuing + # only produces a second, more confusing report from the wreckage. + # UBSAN halt_on_error=1: without it UBSan prints "runtime error: ..." but the + # process exits 0 (recover mode), so gtest/ctest report a pass and the job + # stays green. Combined with -fno-sanitize-recover=undefined at compile time, + # the first finding aborts the test process with a non-zero exit. + export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:print_stacktrace=1:symbolize=1" + export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1:symbolize=1" + fi + # Every TEST() is its own CTest entry, so the suite is process-spawn bound rather than CPU + # bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to + # run concurrently: the only one that claims a fixed resource is single_instance_manager_test, + # which already scopes its local socket name to its own pid so parallel copies cannot collide. + ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}" echo "::endgroup::" fi diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 17c42b07d..57c2993f6 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -144,6 +144,11 @@ jobs: package: DEB + # The one leg that runs the suite under AddressSanitizer and + # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the + # uninstrumented suite, so nothing is lost by instrumenting this one. + sanitize: yes + name: ${{ matrix.distro }} ${{ matrix.version }} needs: configure runs-on: ubuntu-latest # https://github.com/actions/runner-images @@ -174,6 +179,8 @@ jobs: - name: "Build debug and test" if: matrix.test != 'skip' shell: bash + env: + sanitize: '${{ matrix.sanitize }}' run: | source .ci/docker.sh args=() @@ -181,7 +188,10 @@ jobs: args+=(--ccache "$CCACHE_SIZE") args+=(--cmake-generator "$CMAKE_GENERATOR") - RUN --server --debug --test "${args[@]}" + extra=() + [[ $sanitize == yes ]] && extra+=(--sanitize) + + RUN --server --debug --test "${extra[@]}" "${args[@]}" - name: "Build release package" id: build diff --git a/CMakeLists.txt b/CMakeLists.txt index f51c636eb..ef8866e3a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,8 @@ option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) # Compile tests option(TEST "Build tests" OFF) +# Build with AddressSanitizer and UndefinedBehaviorSanitizer +option(ENABLE_SANITIZERS "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) @@ -211,6 +213,10 @@ else() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") endif() +# Always included, because it defines cockatrice_disable_sanitizers_for() for the vendored +# trees. It only touches the global flags when ENABLE_SANITIZERS is on. +include(Sanitizers) + # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") add_definitions("-DSFMT_MEXP=19937") diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index ad52352de..2d74aca4d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -40,7 +40,9 @@ endif() if(TEST) # Union of Qt modules required across all test targets (independent of application targets). # When adding a new test that needs additional Qt modules, add them here rather than in the test's CMakeLists.txt. - set(_TEST_NEEDED Concurrent Network Svg Widgets) + # Test is QtTest: it brings QAbstractItemModelTester and QSignalSpy, which are how the Qt model + # and signal contracts get verified without standing up a GUI. + set(_TEST_NEEDED Concurrent Network Svg Widgets Test) endif() set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED} diff --git a/cmake/Sanitizers.cmake b/cmake/Sanitizers.cmake new file mode 100644 index 000000000..2e5d794de --- /dev/null +++ b/cmake/Sanitizers.cmake @@ -0,0 +1,109 @@ +# AddressSanitizer + UndefinedBehaviorSanitizer opt-in, plus per-source opt-out +# Input: ENABLE_SANITIZERS +# Defines: cockatrice_disable_sanitizers_for +# +# Included once from the root CMakeLists.txt, immediately after the per-compiler flag +# block, so add_compile_options() reaches every library and executable defined below. + +include(CheckCXXSourceCompiles) + +# Opt a set of vendored sources out of instrumentation. +# +# cockatrice_disable_sanitizers_for( ...) +# +# Sanitizer findings inside vendored code are not actionable for us, and letting them +# through buries our own bugs in noise until everyone learns to skip the job. +# +# The sources must be named relative to the directory that compiles them. CMake resolves a +# source-file property against the directory scope the target was declared in, so calling +# this from the root with absolute paths silently does nothing. That is why the call sites +# live next to the add_library/add_executable that pull the vendored code in. +# +# SKIP_PRECOMPILE_HEADERS is not optional. The precompiled header is built with the +# sanitizer flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with +# -fno-sanitize=all does not, and GCC then refuses the PCH with +# "not used because __SANITIZE_ADDRESS__ not defined", which is a fatal -Werror=invalid-pch +# in the servatrice and oracle builds. +function(cockatrice_disable_sanitizers_for) + if(NOT ENABLE_SANITIZERS) + return() + endif() + set_source_files_properties(${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-sanitize=all" SKIP_PRECOMPILE_HEADERS ON) +endfunction() + +if(ENABLE_SANITIZERS) + # Probe a flag the way it will actually be used: on the compile step AND the link step. + # check_cxx_compiler_flag() only probes the compile step, which makes every sanitizer + # flag look unsupported because linking an ASan object without -fsanitize=address leaves + # __asan_init undefined. That is a silent misconfiguration rather than an error, so the + # probe has to link. + function(cockatrice_probe_flag FLAG VARNAME) + set(CMAKE_REQUIRED_FLAGS "${FLAG}") + set(CMAKE_REQUIRED_LINK_OPTIONS "${FLAG}") + check_cxx_source_compiles("int main() { return 0; }" ${VARNAME}) + unset(CMAKE_REQUIRED_FLAGS) + unset(CMAKE_REQUIRED_LINK_OPTIONS) + endfunction() + + set(SANITIZER_FLAGS + -fsanitize=address,undefined + -fno-omit-frame-pointer + # UBSan defaults to recover mode: it prints "runtime error: ..." but the + # process exits 0, so gtest/ctest report a pass and CI stays green. + # -fno-sanitize-recover makes the first finding abort instead. This is + # probed like the other cosmetic flags, but CI also sets + # UBSAN_OPTIONS=halt_on_error=1 as the runtime-side guarantee. + -fno-sanitize-recover=undefined + # Qt builds QObject subclasses into raw storage and writes the vptr afterwards, which + # the vptr check reports on essentially every QObject instantiation. The check has no + # value for us because nothing outside moc-generated code inherits QObject, so it is + # off outright rather than suppressed piecemeal. + -fno-sanitize=vptr + ) + + # MSVC has no ASan/UBSan equivalent, and clang-cl would need /fsanitize=address plus a + # matching runtime link, which is a separate piece of work. + if(MSVC) + message(FATAL_ERROR "ENABLE_SANITIZERS is not supported with MSVC") + endif() + + # Probe rather than assume, so an older toolchain gets the sanitizers it can support + # instead of failing the build on a flag it does not recognize. + set(SANITIZER_SUPPORTED_FLAGS) + foreach(FLAG ${SANITIZER_FLAGS}) + string(MAKE_C_IDENTIFIER "COCKATRICE_HAS${FLAG}" _FLAG_VAR) + cockatrice_probe_flag("${FLAG}" ${_FLAG_VAR}) + if(${_FLAG_VAR}) + list(APPEND SANITIZER_SUPPORTED_FLAGS ${FLAG}) + else() + message(STATUS "Sanitizers: ${FLAG} unsupported by ${CMAKE_CXX_COMPILER_ID}, skipping") + endif() + endforeach() + + # The whole point of the option is the sanitizers, so an unsupported pair is fatal even + # though the cosmetic flags above are allowed to degrade. + if(NOT "-fsanitize=address,undefined" IN_LIST SANITIZER_SUPPORTED_FLAGS) + message(FATAL_ERROR "ENABLE_SANITIZERS requires -fsanitize=address,undefined, which " + "${CMAKE_CXX_COMPILER_ID} does not accept" + ) + endif() + + add_compile_options(${SANITIZER_SUPPORTED_FLAGS}) + add_link_options(-fsanitize=address,undefined) + + # Recorded so the test tree can label its entries and a plain `make` at the same time + # cannot quietly disagree with what CI ran. + set(COCKATRICE_SANITIZERS + ON + CACHE INTERNAL "AddressSanitizer and UBSan are enabled" + ) +else() + # ENABLE_SANITIZERS is OFF, but COCKATRICE_SANITIZERS is CACHE INTERNAL, so a + # reused build dir (e.g. CI's release-package step reconfiguring the same dir + # after the sanitized debug step) would otherwise keep skipping NO_SANITIZE + # tests and keep the 300s discovery timeout. Reset it explicitly. + set(COCKATRICE_SANITIZERS + OFF + CACHE INTERNAL "AddressSanitizer and UBSan are enabled" FORCE + ) +endif() diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index b988c9d2f..16beffcfe 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -11,6 +11,9 @@ add_library( libcockatrice/rng/sfmt/SFMT.c ) +# SFMT is vendored and its own headers trip UBSan's alignment and vptr checks. +cockatrice_disable_sanitizers_for(libcockatrice/rng/sfmt/SFMT.c) + target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE}) diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 0003b77ac..9b6863553 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -79,6 +79,8 @@ if(ZLIB_FOUND) include_directories(${ZLIB_INCLUDE_DIRS}) add_definitions("-DHAS_ZLIB") list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp) + # minizip and zlib are vendored. + cockatrice_disable_sanitizers_for(src/zip/unzip.cpp src/zip/zipglobal.cpp) else() message(STATUS "Oracle: zlib not found; ZIP support disabled") endif() @@ -89,6 +91,8 @@ if(LIBLZMA_FOUND) include_directories(${LIBLZMA_INCLUDE_DIRS}) add_definitions("-DHAS_LZMA") list(APPEND oracle_SOURCES src/lzma/decompress.cpp) + # The LZMA SDK is vendored. + cockatrice_disable_sanitizers_for(src/lzma/decompress.cpp) else() message(STATUS "Oracle: LibLZMA not found; xz support disabled") endif() diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 21f71a908..b176d8009 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -27,6 +27,11 @@ set(servatrice_SOURCES set(servatrice_RESOURCES servatrice.qrc) +# Qxt SMTP is vendored. +cockatrice_disable_sanitizers_for( + src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp +) + if(WIN32) set(servatrice_SOURCES ${servatrice_SOURCES} servatrice.rc) endif(WIN32) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2c288c6ce..c48e6c957 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,109 +2,10 @@ # If a new test needs additional Qt modules, add them there — not in individual test CMakeLists.txt files. enable_testing() -add_test(NAME dummy_test COMMAND dummy_test) -add_test(NAME expression_test COMMAND expression_test) -add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test) -add_test(NAME test_age_formatting COMMAND test_age_formatting) -add_test(NAME password_hash_test COMMAND password_hash_test) -add_test(NAME playmat_resolver_test COMMAND playmat_resolver_test) -add_test(NAME server_card_counter_test COMMAND server_card_counter_test) -add_test(NAME server_counter_test COMMAND server_counter_test) -add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) -add_test(NAME server_developer_role_test COMMAND server_developer_role_test) -add_test(NAME server_game_join_test COMMAND server_game_join_test) -add_test(NAME warning_categories_test COMMAND warning_categories_test) -# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist. -if(TARGET Qt6::lrelease) - add_test(NAME translation_loader_test COMMAND translation_loader_test) -endif() -add_test(NAME report_categories_test COMMAND report_categories_test) -add_test(NAME lag_monitor_test COMMAND lag_monitor_test) -add_test(NAME latency_tracker_test COMMAND latency_tracker_test) -add_test(NAME metrics_registry_test COMMAND metrics_registry_test) -add_test(NAME card_update_progress_test COMMAND card_update_progress_test) -add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test) - -add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) -set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) +include(GoogleTest) # Find GTest -add_executable(dummy_test dummy_test.cpp) -add_executable(expression_test expression_test.cpp) -add_executable(clamped_arithmetic_test clamped_arithmetic_test.cpp) -add_executable(test_age_formatting test_age_formatting.cpp) -add_executable(password_hash_test password_hash_test.cpp) -add_executable(playmat_resolver_test playmat_resolver_test.cpp) -add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) -add_executable(server_card_counter_test server_card_counter_test.cpp) -add_executable(server_counter_test server_counter_test.cpp) -add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) -add_executable(server_developer_role_test server_developer_role_test.cpp) -add_executable(server_game_join_test server_game_join_test.cpp) -add_executable(warning_categories_test warning_categories_test.cpp) -if(TARGET Qt6::lrelease) - add_executable(translation_loader_test translation_loader_test.cpp) - - # QTranslator only accepts real .qm files, so the translation loader test gets one compiled from its - # own .ts fixture rather than a stub. - set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm") - add_custom_command( - OUTPUT "${TRANSLATION_LOADER_TEST_QM}" - COMMAND Qt6::lrelease "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" -qm "${TRANSLATION_LOADER_TEST_QM}" - DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" - COMMENT "Compiling translation_loader_test.ts" - ) - add_custom_target(translation_loader_test_qm DEPENDS "${TRANSLATION_LOADER_TEST_QM}") - add_dependencies(translation_loader_test translation_loader_test_qm) - target_compile_definitions(translation_loader_test PRIVATE TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}") - - # A multi-config generator (MSBuild, Xcode) puts the executable in a per-config subdirectory while - # the .qm stays in the current binary directory, so copy the fixture next to the binary: that is the - # layout ABuildTreeFindsTheTranslationsNextToTheExecutable is about, and the only one it finds on - # those generators. - add_custom_command( - TARGET translation_loader_test - POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TRANSLATION_LOADER_TEST_QM}" - "$/" - VERBATIM - ) -endif() -add_executable(report_categories_test report_categories_test.cpp) -add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) -target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) -add_executable(latency_tracker_test latency_tracker_test.cpp) -add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp) -add_executable( - card_update_progress_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_database_update/card_update_progress.cpp - card_update_progress_test.cpp -) -target_include_directories(card_update_progress_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) -add_executable( - loader_local_matching_test - ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp - ${VERSION_STRING_CPP} - loader_local_matching_test.cpp -) -target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) - -# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. -if(WITH_CLIENT) - add_executable( - single_instance_manager_test ${CMAKE_SOURCE_DIR}/cockatrice/src/single_instance_manager.cpp - single_instance_manager_test.cpp - ) - target_include_directories(single_instance_manager_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) - target_include_directories(single_instance_manager_test PRIVATE ${CMAKE_SOURCE_DIR}/libcockatrice_utility) - - add_test(NAME single_instance_manager_test COMMAND single_instance_manager_test) -endif() - find_package(GTest) if(NOT GTEST_FOUND) @@ -129,93 +30,330 @@ if(NOT GTEST_FOUND) target_include_directories(gtest INTERFACE "$") set(GTEST_INCLUDE_DIRS "${CMAKE_BINARY_DIR}/gtest-src/include") - set(GTEST_BOTH_LIBRARIES gtest) - add_dependencies(dummy_test gtest) - add_dependencies(expression_test gtest) - add_dependencies(clamped_arithmetic_test gtest) - add_dependencies(test_age_formatting gtest) - add_dependencies(password_hash_test gtest) - add_dependencies(playmat_resolver_test gtest) - add_dependencies(deck_hash_performance_test gtest) - add_dependencies(server_card_counter_test gtest) - add_dependencies(server_counter_test gtest) - add_dependencies(server_rate_limiter_test gtest) - add_dependencies(server_developer_role_test gtest) - add_dependencies(server_game_join_test gtest) - add_dependencies(warning_categories_test gtest) - if(TARGET translation_loader_test) - add_dependencies(translation_loader_test gtest) - endif() - add_dependencies(report_categories_test gtest) - add_dependencies(lag_monitor_test gtest) - add_dependencies(latency_tracker_test gtest) - add_dependencies(metrics_registry_test gtest) - add_dependencies(card_update_progress_test gtest) - add_dependencies(loader_local_matching_test gtest) - add_dependencies(single_instance_manager_test gtest) + # gtest_main is not optional here. FindGTest sets GTEST_BOTH_LIBRARIES to both halves, so this + # has to match it or the two paths disagree about what provides main(). A test source that + # defines its own main() gets away with the omission; one that does not fails at link with + # `undefined reference to main` pointing at crt1.o, which names neither gtest nor this line. + set(GTEST_BOTH_LIBRARIES gtest gtest_main) endif() +# Directory-scoped on purpose: the test subdirectories below add targets that include the +# fetched gtest headers and do not repeat this path themselves. include_directories(${GTEST_INCLUDE_DIRS}) -target_link_libraries(dummy_test Threads::Threads ${GTEST_BOTH_LIBRARIES}) -target_link_libraries(expression_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) -target_link_libraries( - clamped_arithmetic_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_link_libraries( - test_age_formatting libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_link_libraries( - password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_link_libraries( - playmat_resolver_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -target_link_libraries( - deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -target_link_libraries( - server_card_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_link_libraries( - server_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_link_libraries( - server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_link_libraries( - server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -target_link_libraries( - server_game_join_test libcockatrice_network_server_remote libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -target_link_libraries( - warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -if(TARGET translation_loader_test) + +# --- Test registration ---------------------------------------------------------- +# +# Every test target needs the same four steps: build the executable, depend on gtest when it +# was fetched from source, link the shared test libraries, and hand the cases to CTest. The +# `if(NOT GTEST_FOUND)` branch is the easy one to forget — omit it and the link fails with +# undefined gtest symbols — so it lives here once instead of being repeated per test. +# +# This also replaces `add_test(NAME x COMMAND x)`. Naming the binary as a single CTest entry +# hides which case failed and makes `ctest -R Suite.Case` impossible; discovery exposes each +# TEST() as its own CTest entry instead. +# +# Labels: exactly one per test, because gtest_discover_tests() cannot carry more than one. +# GoogleTestAddTests.cmake accumulates the generated script with `foreach(arg ${ARGN})`, so a +# label list serialises as bare `LABELS ci server` in the CTestTestfile, and CTest reads only +# the first value of a property and silently discards the rest. A label therefore classifies a +# test into exactly one group, and the three groups cover every selection worth making: +# +# ci the default hermetic set, so `ctest -LE bench` runs everything but benchmarks +# server servatrice-facing tests, so `ctest -L server` works +# bench the slow suites, kept out of the default run +# +# A second label is a configure-time FATAL_ERROR rather than a line CTest quietly reads as one. +# Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it +# composes with the group instead of competing for the single slot. NO_SANITIZE is such a flag. +# +# TIMEOUT is bounded per test because CTest's implicit default is 1500s: one hung socket wait +# would otherwise stall CI for 25 minutes. +# +# EXTRA_LIBS is appended after the shared libraries so a caller adding a static archive (zlib, +# lzma) keeps it last, which is where the linker needs it. + +function(cockatrice_add_test) + cmake_parse_arguments( + PARSE_ARGV 0 ARG "MANUAL;NO_SANITIZE" "NAME" "SOURCES;LIBS;LABELS;DEFINITIONS;INCLUDE_DIRS;EXTRA_LIBS" + ) + + if(NOT ARG_NAME OR NOT ARG_SOURCES) + message(FATAL_ERROR "cockatrice_add_test requires at least NAME and SOURCES") + endif() + + add_executable(${ARG_NAME} ${ARG_SOURCES}) + + if(ARG_INCLUDE_DIRS) + target_include_directories(${ARG_NAME} PRIVATE ${ARG_INCLUDE_DIRS}) + endif() + + if(ARG_DEFINITIONS) + target_compile_definitions(${ARG_NAME} PRIVATE ${ARG_DEFINITIONS}) + endif() + target_link_libraries( - translation_loader_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} + ${ARG_NAME} ${ARG_LIBS} Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ${ARG_EXTRA_LIBS} + ) + + # Only needed when gtest was downloaded and add_subdirectory'd above. An imported gtest + # target already carries its own dependency, so this must stay conditional. + if(NOT GTEST_FOUND) + add_dependencies(${ARG_NAME} gtest gtest_main) + endif() + + # MANUAL targets are built but deliberately kept out of ctest: benchmarks that need a card + # database, hit the network, or measure RAM. Leaving them unregistered keeps CI hermetic + # and fast. They still link gtest so they can use TEST() without a second code path. + if(ARG_MANUAL) + return() + endif() + + # NO_SANITIZE marks a target the sanitizer leg cannot run. Excluded by not handing its cases + # to CTest at all, rather than by a label, so the exclusion cannot rot into a line CTest + # quietly reads as something else. The target still builds, and still runs on the other legs. + if(ARG_NO_SANITIZE AND COCKATRICE_SANITIZERS) + return() + endif() + + # One label only, per the note above. Rejecting a second loudly beats emitting a + # CTestTestfile line that CTest will quietly read as one label. + list(LENGTH ARG_LABELS _label_count) + if(_label_count GREATER 1) + message(FATAL_ERROR "${ARG_NAME}: pass at most one label, got '${ARG_LABELS}'") + endif() + if(NOT ARG_LABELS) + set(ARG_LABELS ci) + endif() + + # GoogleTestAddTests defaults DISCOVERY_TIMEOUT to 5s, which is ample for a plain debug + # binary (--gtest_list_tests costs 0.04s) but not for an instrumented one (~2.8s cold, and + # discovery runs several binaries concurrently behind a -j build). Left at the default, a + # sanitizer build fails at the *link* step with a bare "Process terminated due to timeout" + # and no hint that discovery, not the test, is what is slow. + if(COCKATRICE_SANITIZERS) + set(_discovery_timeout 300) + else() + set(_discovery_timeout 60) + endif() + + gtest_discover_tests( + ${ARG_NAME} DISCOVERY_MODE POST_BUILD DISCOVERY_TIMEOUT ${_discovery_timeout} + PROPERTIES LABELS "${ARG_LABELS}" + TIMEOUT 60 + ) +endfunction() + +# Same wiring for targets that are built but deliberately kept out of ctest: benchmarks that +# need a card database, hit the network, or measure RAM. A separate name rather than a MANUAL +# flag spelled out at each call site, so "this is not in the CI suite" is visible in the CMake +# file. Note the unquoted expansions: an unset multi-value keyword expands to zero arguments, +# which is what lets this forward a caller's keywords verbatim without tripping the FATAL_ERROR +# guard above on an empty list. +function(cockatrice_add_manual_test) + cmake_parse_arguments(PARSE_ARGV 0 ARG "" "NAME" "SOURCES;LIBS;DEFINITIONS;INCLUDE_DIRS;EXTRA_LIBS") + + cockatrice_add_test( + NAME + ${ARG_NAME} + SOURCES + ${ARG_SOURCES} + LIBS + ${ARG_LIBS} + DEFINITIONS + ${ARG_DEFINITIONS} + INCLUDE_DIRS + ${ARG_INCLUDE_DIRS} + EXTRA_LIBS + ${ARG_EXTRA_LIBS} + MANUAL + ) +endfunction() + +# --- Unit tests ------------------------------------------------------------------ + +cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test( + NAME + playmat_resolver_test + SOURCES + playmat_resolver_test.cpp + LIBS + libcockatrice_deck_list + libcockatrice_utility +) +cockatrice_add_test( + NAME + server_rate_limiter_test + SOURCES + server_rate_limiter_test.cpp + LIBS + libcockatrice_utility + LABELS + server +) +cockatrice_add_test( + NAME + server_card_counter_test + SOURCES + server_card_counter_test.cpp + LIBS + libcockatrice_network + LABELS + server +) +cockatrice_add_test( + NAME + server_counter_test + SOURCES + server_counter_test.cpp + LIBS + libcockatrice_network + LABELS + server +) +cockatrice_add_test( + NAME + server_developer_role_test + NO_SANITIZE + SOURCES + server_developer_role_test.cpp + LIBS + libcockatrice_network + libcockatrice_rng + LABELS + server +) +cockatrice_add_test( + NAME + server_game_join_test + SOURCES + server_game_join_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) +cockatrice_add_test( + NAME + latency_tracker_test + SOURCES + latency_tracker_test.cpp + LIBS + libcockatrice_network + LABELS + server +) +cockatrice_add_test( + NAME + lag_monitor_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp + lag_monitor_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + metrics_registry_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/metrics_registry.cpp + metrics_registry_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) +cockatrice_add_test( + NAME + card_update_progress_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_database_update/card_update_progress.cpp + card_update_progress_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + loader_local_matching_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + loader_local_matching_test.cpp + LIBS + libcockatrice_settings + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + deck_hash_performance_test + SOURCES + deck_hash_performance_test.cpp + LIBS + libcockatrice_deck_list + libcockatrice_utility + LABELS + bench +) + +# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist. +if(TARGET Qt6::lrelease) + set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm") + add_custom_command( + OUTPUT "${TRANSLATION_LOADER_TEST_QM}" + COMMAND Qt6::lrelease "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" -qm "${TRANSLATION_LOADER_TEST_QM}" + DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" + COMMENT "Compiling translation_loader_test.ts" + ) + add_custom_target(translation_loader_test_qm DEPENDS "${TRANSLATION_LOADER_TEST_QM}") + + cockatrice_add_test( + NAME + translation_loader_test + SOURCES + translation_loader_test.cpp + LIBS + libcockatrice_utility + DEFINITIONS + TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}" + ) + add_dependencies(translation_loader_test translation_loader_test_qm) + + # A multi-config generator (MSBuild, Xcode) puts the executable in a per-config subdirectory while + # the .qm stays in the current binary directory, so copy the fixture next to the binary: that is the + # layout ABuildTreeFindsTheTranslationsNextToTheExecutable is about, and the only one it finds on + # those generators. + add_custom_command( + TARGET translation_loader_test + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TRANSLATION_LOADER_TEST_QM}" + "$/" + VERBATIM ) endif() -target_link_libraries( - report_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) -target_link_libraries( - latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src) -target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES}) -target_link_libraries(card_update_progress_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) -target_link_libraries( - loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) +# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. if(WITH_CLIENT) - target_link_libraries(single_instance_manager_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) + cockatrice_add_test( + NAME + single_instance_manager_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/single_instance_manager.cpp + single_instance_manager_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_utility + ) endif() add_subdirectory(card_zone_algorithms) diff --git a/tests/card_zone_algorithms/CMakeLists.txt b/tests/card_zone_algorithms/CMakeLists.txt index e03d8f77d..945f8a0ed 100644 --- a/tests/card_zone_algorithms/CMakeLists.txt +++ b/tests/card_zone_algorithms/CMakeLists.txt @@ -1,15 +1,4 @@ -add_executable(card_zone_algorithms_test card_zone_algorithms_test.cpp) - -target_include_directories(card_zone_algorithms_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones) - -target_link_libraries( - card_zone_algorithms_test - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} +cockatrice_add_test( + NAME card_zone_algorithms_test SOURCES card_zone_algorithms_test.cpp INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones ) - -add_test(NAME card_zone_algorithms_test COMMAND card_zone_algorithms_test) - -if(NOT GTEST_FOUND) - add_dependencies(card_zone_algorithms_test gtest) -endif() diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 811cebc1d..22171a9e8 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -9,61 +9,46 @@ add_definitions("-DCARDDB_DATADIR=\"${CMAKE_CURRENT_SOURCE_DIR}/data/\"") # ------------------------ # Card Database Test # ------------------------ -add_executable(carddatabase_test ${MOCKS_SOURCES} ${VERSION_STRING_CPP} carddatabase_test.cpp mocks.cpp) - -target_link_libraries( +cockatrice_add_test( + NAME carddatabase_test - PRIVATE libcockatrice_card - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} + SOURCES + ${VERSION_STRING_CPP} + carddatabase_test.cpp + mocks.cpp + LIBS + libcockatrice_card ) -add_test(NAME carddatabase_test COMMAND carddatabase_test) - -# ------------------------ -# Load Benchmark (manual, not run in CI) -# ------------------------ -add_executable(load_benchmark ${MOCKS_SOURCES} ${VERSION_STRING_CPP} load_benchmark.cpp mocks.cpp) - -target_link_libraries( - load_benchmark - PRIVATE libcockatrice_card - PRIVATE libcockatrice_models - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} -) - -if(NOT GTEST_FOUND) - add_dependencies(load_benchmark gtest) -endif() - # ------------------------ # Filter String Test # (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) - add_executable(filter_string_test ${MOCKS_SOURCES} ${VERSION_STRING_CPP} filter_string_test.cpp mocks.cpp) - - target_link_libraries( + cockatrice_add_test( + NAME filter_string_test - PRIVATE libcockatrice_filters - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} + SOURCES + ${VERSION_STRING_CPP} + filter_string_test.cpp + mocks.cpp + LIBS + libcockatrice_filters ) - - add_test(NAME filter_string_test COMMAND filter_string_test) - - if(NOT GTEST_FOUND) - add_dependencies(filter_string_test gtest) - endif() endif() # ------------------------ -# Dependencies on gtest +# Load Benchmark (manual, not run in CI) # ------------------------ -if(NOT GTEST_FOUND) - add_dependencies(carddatabase_test gtest) -endif() +cockatrice_add_manual_test( + NAME + load_benchmark + SOURCES + ${VERSION_STRING_CPP} + load_benchmark.cpp + mocks.cpp + LIBS + libcockatrice_card + libcockatrice_models + MANUAL +) diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt index e3096c559..cd7d693f3 100644 --- a/tests/deck_list_model/CMakeLists.txt +++ b/tests/deck_list_model/CMakeLists.txt @@ -1,33 +1,23 @@ -add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_model_custom_zones_test gtest) -endif() - -target_link_libraries( +cockatrice_add_test( + NAME deck_list_model_custom_zones_test + SOURCES + ${VERSION_STRING_CPP} + deck_list_model_custom_zones_test.cpp + LIBS libcockatrice_models libcockatrice_card libcockatrice_deck_list - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} ) -add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test) -add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_model_zone_integration_test gtest) -endif() - -target_link_libraries( +cockatrice_add_test( + NAME deck_list_model_zone_integration_test + SOURCES + ${VERSION_STRING_CPP} + deck_list_model_zone_integration_test.cpp + LIBS libcockatrice_models libcockatrice_card libcockatrice_deck_list - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} ) -add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test) diff --git a/tests/deck_list_zones/CMakeLists.txt b/tests/deck_list_zones/CMakeLists.txt index 0710be94d..7af8fd187 100644 --- a/tests/deck_list_zones/CMakeLists.txt +++ b/tests/deck_list_zones/CMakeLists.txt @@ -1,10 +1 @@ -add_executable(deck_list_zones_test deck_list_zones_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_zones_test gtest) -endif() - -target_link_libraries( - deck_list_zones_test libcockatrice_deck_list Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -add_test(NAME deck_list_zones_test COMMAND deck_list_zones_test) +cockatrice_add_test(NAME deck_list_zones_test SOURCES deck_list_zones_test.cpp LIBS libcockatrice_deck_list) diff --git a/tests/dummy_test.cpp b/tests/dummy_test.cpp deleted file mode 100644 index 8fd9c78dd..000000000 --- a/tests/dummy_test.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "gtest/gtest.h" -#include - -namespace -{ -class FooTest : public ::testing::Test -{ -}; - -TEST(DummyTest, Works) -{ - ASSERT_EQ(1, 1) << "One is not equal to one"; -} -} // namespace - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/tests/loading_from_clipboard/CMakeLists.txt b/tests/loading_from_clipboard/CMakeLists.txt index 719d62f45..88711e457 100644 --- a/tests/loading_from_clipboard/CMakeLists.txt +++ b/tests/loading_from_clipboard/CMakeLists.txt @@ -1,12 +1,13 @@ add_definitions("-DCARDDB_DATADIR=\"${CMAKE_CURRENT_SOURCE_DIR}/data/\"") -add_executable(loading_from_clipboard_test ${VERSION_STRING_CPP} clipboard_testing.cpp loading_from_clipboard_test.cpp) -if(NOT GTEST_FOUND) - add_dependencies(loading_from_clipboard_test gtest) -endif() - -target_link_libraries( - loading_from_clipboard_test libcockatrice_deck_list libcockatrice_card Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + loading_from_clipboard_test + SOURCES + ${VERSION_STRING_CPP} + clipboard_testing.cpp + loading_from_clipboard_test.cpp + LIBS + libcockatrice_deck_list + libcockatrice_card ) -add_test(NAME loading_from_clipboard_test COMMAND loading_from_clipboard_test) diff --git a/tests/movecard_tests/CMakeLists.txt b/tests/movecard_tests/CMakeLists.txt index 769047148..68d8fc84c 100755 --- a/tests/movecard_tests/CMakeLists.txt +++ b/tests/movecard_tests/CMakeLists.txt @@ -1,16 +1,11 @@ -add_executable(reverse_card_move_test reverse_card_move_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(reverse_card_move_test gtest) -endif() - -target_link_libraries( +cockatrice_add_test( + NAME reverse_card_move_test - PRIVATE libcockatrice_network_server_remote - PRIVATE libcockatrice_rng - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} + SOURCES + reverse_card_move_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server ) - -add_test(NAME reverse_card_move_test COMMAND reverse_card_move_test) diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 9bc5ee5be..c896c7960 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -1,37 +1,31 @@ -add_executable(parse_cipt_test ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(parse_cipt_test gtest) -endif() - -target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) - -add_test(NAME parse_cipt_test COMMAND parse_cipt_test) +cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) # Oracle importer unit tests -add_executable( - oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - ../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp +cockatrice_add_test( + NAME + oracle_importer_test + SOURCES + ${VERSION_STRING_CPP} + ../../oracle/src/oracleimporter.cpp + ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp + oracle_importer_test.cpp + LIBS + libcockatrice_card + libcockatrice_interfaces ) -if(NOT GTEST_FOUND) - add_dependencies(oracle_importer_test gtest) -endif() - -target_link_libraries( - oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) - -add_test(NAME oracle_importer_test COMMAND oracle_importer_test) - # Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) # Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark # can download and decompress whatever AllPrintings format the default URL selects. +# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also +# reach the two unit tests above. That is harmless: those macros gate code only in +# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles. find_package(ZLIB) if(ZLIB_FOUND) add_definitions("-DHAS_ZLIB") set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES}) set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) include_directories(${ZLIB_INCLUDE_DIRS}) else() @@ -42,28 +36,27 @@ find_package(LibLZMA) if(LIBLZMA_FOUND) add_definitions("-DHAS_LZMA") list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) include_directories(${LIBLZMA_INCLUDE_DIRS}) else() message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") endif() -add_executable( - oracle_importer_benchmark_test - ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - ../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES} -) - -if(NOT GTEST_FOUND) - add_dependencies(oracle_importer_benchmark_test gtest) -endif() - -target_link_libraries( +cockatrice_add_manual_test( + NAME oracle_importer_benchmark_test + SOURCES + ${VERSION_STRING_CPP} + ../../oracle/src/oracleimporter.cpp + ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp + oracle_importer_benchmark_test.cpp + ${_ORACLE_BENCH_EXTRA_SOURCES} + LIBS libcockatrice_card libcockatrice_interfaces - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} + EXTRA_LIBS ${_ORACLE_BENCH_EXTRA_LIBRARIES} + MANUAL ) diff --git a/tests/picture_loader_benchmark/CMakeLists.txt b/tests/picture_loader_benchmark/CMakeLists.txt index a3b75fe88..0684f58f4 100644 --- a/tests/picture_loader_benchmark/CMakeLists.txt +++ b/tests/picture_loader_benchmark/CMakeLists.txt @@ -1,8 +1,10 @@ -# Manual benchmark hitting the real card image hosts. Not registered with -# add_test(): it requires a cards.xml, touches the network for minutes at a -# time, and needs network access. Build it explicitly and run by hand. -add_executable( +# Manual benchmark hitting the real card image hosts. Not registered with CTest: it requires a +# cards.xml, touches the network for minutes at a time, and needs network access. Build it +# explicitly and run by hand. +cockatrice_add_manual_test( + NAME picture_loader_benchmark_test + SOURCES ${VERSION_STRING_CPP} ../../cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp ../../cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -11,16 +13,12 @@ add_executable( ../../cockatrice/src/client/settings/cache_settings.h picture_loader_benchmark.cpp settings_cache_mock.cpp -) - -target_include_directories(picture_loader_benchmark_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) - -target_link_libraries( - picture_loader_benchmark_test + LIBS libcockatrice_card libcockatrice_settings libcockatrice_interfaces libcockatrice_utility - Threads::Threads - ${TEST_QT_MODULES} + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + MANUAL ) diff --git a/tests/settings/CMakeLists.txt b/tests/settings/CMakeLists.txt index 7ee32a645..98bed1f4b 100644 --- a/tests/settings/CMakeLists.txt +++ b/tests/settings/CMakeLists.txt @@ -1,26 +1,14 @@ -add_executable(settings_manager_test settings_manager_test.cpp) -add_executable(settings_defaults_test settings_defaults_test.cpp) -add_executable(settings_migration_test settings_migration_test.cpp) +cockatrice_add_test(NAME settings_manager_test SOURCES settings_manager_test.cpp LIBS libcockatrice_settings) -target_link_libraries( - settings_manager_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + settings_defaults_test + SOURCES + settings_defaults_test.cpp + LIBS + libcockatrice_settings + DEFINITIONS + SETTINGS_UNIT_TEST ) -target_link_libraries( - settings_defaults_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -target_compile_definitions(settings_defaults_test PRIVATE SETTINGS_UNIT_TEST) - -target_link_libraries( - settings_migration_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) - -add_test(NAME settings_manager_test COMMAND settings_manager_test) -add_test(NAME settings_defaults_test COMMAND settings_defaults_test) -add_test(NAME settings_migration_test COMMAND settings_migration_test) - -if(NOT GTEST_FOUND) - add_dependencies(settings_manager_test gtest) - add_dependencies(settings_defaults_test gtest) - add_dependencies(settings_migration_test gtest) -endif() +cockatrice_add_test(NAME settings_migration_test SOURCES settings_migration_test.cpp LIBS libcockatrice_settings) diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index ac202210e..93493ee5f 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -1,5 +1,7 @@ -add_executable( +cockatrice_add_test( + NAME theme_migration_test + SOURCES ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp @@ -9,22 +11,11 @@ add_executable( ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp ${VERSION_STRING_CPP} theme_migration_test.cpp -) -target_include_directories(theme_migration_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) - -target_link_libraries( - theme_migration_test + LIBS libcockatrice_settings libcockatrice_network libcockatrice_network_server_remote libcockatrice_protocol - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src ) - -add_test(NAME theme_migration_test COMMAND theme_migration_test) - -if(NOT GTEST_FOUND) - add_dependencies(theme_migration_test gtest) -endif() From bdda3dedfaf58065f167bbc26a62b8685f2cb427 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:05:28 +0200 Subject: [PATCH 007/247] [CI] Publish a gcovr line-coverage report from the test run (#7404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Tests] Register each test case with CTest instead of each test binary Registering a test cost four edits across a dozen CMakeLists files: add_test, add_executable, a conditional add_dependencies on gtest, and target_link_libraries. The conditional branch was the trap -- it is only needed when GTest was fetched and add_subdirectory'd, and omitting it yields undefined gtest symbols at link time -- so it was easy to leave out and impossible to notice until the link failed. Fold all four steps into cockatrice_add_test() and migrate every test directory onto it, with a cockatrice_add_manual_test() wrapper for the three benchmarks that are built but deliberately kept out of ctest. The calls now read as a declaration of what a test is rather than a transcript of how to register one. While rewriting the registrations, replace add_test(NAME x COMMAND x) with gtest_discover_tests(). Naming the binary as a single CTest entry meant a CI failure reported only which binary failed, and ctest -R Suite.Case could not select an individual case. Discovery raises the suite from 35 opaque entries to 469 real ones: ctest -R ThemeMigrationTest.IsIdempotent now runs exactly that case, and ctest -L server / -LE bench work off labels applied at registration. Give every test a 60s TIMEOUT. CTest's implicit default is 1500s, so one hung socket wait would stall CI for 25 minutes, and single_instance_manager_test already does real QLocalSocket waits. Dropping per-case granularity costs wall-clock time, since the suite becomes process-spawn bound rather than CPU bound: serial ctest went from 3.8s to 30.8s. Run it with -j in CI to recover it -- 7.6s at -j4, and 3.5s at -j8, both at or below the old serial time. The binaries are parallel-safe: the only test claiming a fixed resource scopes its local socket name to its own pid, so concurrent copies cannot collide. Also delete dummy_test, which asserted 1 == 1 and only proved the harness was wired up -- something the other 468 cases already establish. No test is added, removed, or changed by this commit; the 469 cases are the same ones the 35 binaries contained. * [Tests] Make QtTest available to test targets QtTest was not among the modules test targets could link, so the Qt model and signal contracts had no way to be verified from a test: QAbstractItemModelTester and QSignalSpy both live there. Add Test to _TEST_NEEDED in cmake/FindQtRuntime.cmake. That is the single place the union of test-only Qt modules is declared, so this is the only file that needs to change to make the module reachable from every test target. Nothing uses it yet. This lands the capability ahead of the model-layer tests that need it, so those can be added as tests alone rather than as test plus build-system plumbing. * [Tests] Correct the note on the oracle test compression defines add_definitions() populates the directory's COMPILE_DEFINITIONS property, which is consumed at generate time, so it reaches every target in the directory no matter where it is called relative to add_executable(). The previous note claimed the flags stayed confined to the benchmark because the find_package block came after it; oracle_importer_test, created above that block, carries both defines too. Verified harmless rather than a live bug: HAS_ZLIB and HAS_LZMA gate code only in oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, and no test target compiles either. The ordering and the leaked defines are both pre-existing, so this only corrects the explanation. * [Tests] Carry one CTest label per test instead of several gtest_discover_tests() can hand CTest exactly one label. GoogleTestAddTests.cmake accumulates its generated script with `foreach(arg ${ARGN})`, so a label list reaches the CTestTestfile as bare `LABELS ci server`, and CTest reads only the first value of a property and drops the rest without a word. The `ci` label that is appended to every registered test was therefore absent from every test that also asked for `server` or `bench`: `ctest -L ci` reported 400 of 469 cases, and `ctest --print-labels` never listed a second label anywhere in the build. Reduce each test to exactly one of `ci`, `server`, or `bench`, which is all the selections need. The three now partition the suite exactly, 400 + 64 + 5 = 469, and `ctest -LE bench` still means everything but benchmarks. A second label is now a configure-time FATAL_ERROR rather than a line that quietly means something else. Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it composes with the group instead of competing for the single slot. Also raise DISCOVERY_TIMEOUT off GoogleTestAddTests' 5s default. A plain debug binary answers --gtest_list_tests in 0.04s, but that budget is thin once several binaries discover concurrently behind a -j build, and being over it fails at the *link* step with a bare "Process terminated due to timeout" that points nowhere near the cause. * [Tests] Link gtest_main on the fetched-gtest path The downloaded-gtest branch set GTEST_BOTH_LIBRARIES to `gtest` alone, while FindGTest sets it to both halves. Every test target links that list rather than picking an entry point, so on this path nothing provides main() and any test source that does not define its own fails at link with `undefined reference to main` pointing at crt1.o, naming neither gtest nor the line responsible. That stayed latent while every test carried its own four-line main(). A test that omits it now fails, and the error points at the C runtime rather than at gtest, which is a long way from the cause. Restoring both halves makes the two paths agree and drops the boilerplate requirement on new test sources. The 18 sources that still define main() keep linking: gtest_main.cc defines nothing but main, so the linker leaves its object in the archive unused rather than reporting a duplicate. * [Build] Add an ENABLE_SANITIZERS option for ASan and UBSan Adds -DENABLE_SANITIZERS, off by default, building with AddressSanitizer and UndefinedBehaviorSanitizer. The bulk of the work is in the three ways the obvious implementation silently does the wrong thing. Probe the flags on the link step, not just the compile step. check_cxx_compiler_flag() only compiles, so every sanitizer flag comes back unsupported, because linking an ASan object without -fsanitize=address leaves __asan_init undefined. The result is not a build failure but a build that appears instrumented and is not, which is worse. Use check_cxx_source_compiles() with CMAKE_REQUIRED_LINK_OPTIONS instead, and treat an unsupported sanitizer pair as fatal since the cosmetic flags are allowed to degrade. Opt vendored sources out per source, through a cockatrice_disable_sanitizers_for() helper rather than a list of set_source_files_properties() calls at the root. The helper has to exist because CMake resolves a source-file property against the directory scope its target was declared in: setting the property from the root with absolute paths is a no-op, silently, which is why the call sites sit next to the add_library and add_executable that pull the vendored code in. SFMT, minizip, the LZMA SDK and Qxt SMTP are excluded. oracle/src/qt-json is in the history but not in the tree, removed in #7214. Also mark those sources SKIP_PRECOMPILE_HEADERS. The PCH is built with the sanitizer flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with -fno-sanitize=all does not and GCC then refuses the PCH, which is a fatal -Werror=invalid-pch in the servatrice and oracle builds. -fno-sanitize=vptr is not optional. Qt constructs QObject subclasses into raw storage and writes the vptr afterwards, so the vptr check reports on essentially every QObject instantiation, and it has no value here because nothing outside moc-generated code inherits QObject. Verified on the resulting build: our own translation units carry ASan and UBSan calls, SFMT.o carries neither, and the full suite of 464 cases passes clean under ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 with UBSAN_OPTIONS=halt_on_error=0. The test suite needs two adjustments to make this option usable at all, both of which land here because they are consequences of COCKATRICE_SANITIZERS existing rather than independent changes. DISCOVERY_TIMEOUT rises from 60s to 300s under sanitizers. An instrumented binary takes 2.8s to answer --gtest_list_tests against 0.04s uninstrumented, and discovery runs several binaries at once behind a -j build. One target is excluded via a NO_SANITIZE flag, server_developer_role_test. Reaching google::protobuf::TextFormat::Printer's destructor aborts on an absl assertion inside system protobuf 33.4 whenever the consumer translation unit is built without NDEBUG, which a Debug sanitizer build is. This reproduces with no Cockatrice code at all, just "TextFormat::Printer p; p.PrintToString(msg, &out);" built with -fsanitize=address, so it is upstream rather than ours. It is a flag and not a fourth label because "skip this in the sanitizer leg" has to compose with a test's group rather than compete for the single label slot, and because a flag excludes by not registering the cases at all, which cannot rot the way a label silently can. The target still builds and still runs on the legs without sanitizers. * [CI] Run the suite once under ASan and UBSan Adds --sanitize to .ci/compile.sh, mirroring --test, and turns it on for one entry of the existing Linux Docker matrix: Ubuntu 26.04, which already had a test-enabled leg. That reuses the image, the ccache layer and the existing step shape, so the CI diff is a matrix key plus the argument plumbing. Debian 13 and Fedora 44 keep covering the uninstrumented suite, so nothing is lost by instrumenting one leg. Sanitizers stay off the release and package legs, which build with --release for packaging: instrumenting those would change the shipped binaries' optimization profile and build time for no benefit. macOS and Windows stay uninstrumented too, since ASan is unreliable against the Qt build there and MSVC has no equivalent without /fsanitize=address. --sanitize does not imply --test, so an instrumented build can be produced locally without paying for ctest. When both are given, ctest runs with detect_leaks=0 because Qt and the sanitizer runtimes both leak at exit by design and LeakSanitizer's default buries real findings under hundreds of process-teardown reports, and with UBSan halt_on_error=0 so one run collects many findings instead of stopping at the first. ASan keeps abort_on_error=1, since a bad access leaves the process unusable and continuing only produces a second, more confusing report from the wreckage. * [CI] Publish a gcovr line-coverage report from the test run Sanitizers tell us what a run did wrong; they say nothing about what it never reached. This adds the other half: an instrumented build whose report says which of our own lines no test executes. Add an ENABLE_COVERAGE option that compiles with --coverage -O0 -g, mirroring the ENABLE_SANITIZERS opt-in including the per-source carve-out that keeps vendored trees out of the denominator. -O0 is deliberate: at higher optimisation levels GCC merges and relocates lines, so the report would describe code that no longer exists in that form. Only GCC is supported, since Clang's intermediate format needs llvm-cov; asking for coverage under Clang is a hard error rather than a build that silently produces unreadable .gcno files. Keep the filters in a committed gcovr.cfg rather than in the invocation. The tests themselves are excluded because they are compiled with the same instrumentation and are only ever executed by the run doing the measuring, so leaving them in would report a healthier project than we have. The process entry points and the client's window chrome go too: each needs a display, an interactive terminal or a live database before it does anything a headless test could observe, so they would otherwise sit at 0% forever and bury real movement. Give coverage its own matrix leg rather than a second flag on the sanitizer leg. The two answer different questions, and combining them slows both down without telling us more. The new variant key gives the leg its own Docker image and ccache entry so the two legs cannot race on the same tag. The leg publishes an HTML report with per-file pages, LCOV, Cobertura XML and a JSON summary. gcovr is a report-time dependency rather than a build dependency, so a missing one warns and skips instead of failing an otherwise green build. * [CI] Resolve the coverage source root before the build dir cd The previous commit computed SRC_ROOT after `cd "$BUILD_DIR"`, under a comment claiming it ran before. `$0` is relative to the directory the script was invoked from, so from inside the build dir `dirname "$0"` no longer resolves and the subshell's `cd` fails. Under `set -e` that is fatal rather than an empty variable, which took down every leg in the matrix, coverage or not, including the ones this change does not otherwise touch. Move it above the cd and guard it on MAKE_COVERAGE, since the coverage report is the only consumer. Resolving it unconditionally meant a coverage-only detail was paid for by every build in the fleet, and gave every one of them a way to fail. --------- Co-authored-by: Lukas Brübach --- .ci/Ubuntu26.04/Dockerfile | 1 + .ci/compile.sh | 47 +++++++++++++++++- .ci/docker.sh | 10 +++- .github/workflows/desktop-build.yml | 44 +++++++++++++++-- CMakeLists.txt | 8 ++- cmake/Coverage.cmake | 75 +++++++++++++++++++++++++++++ gcovr.cfg | 63 ++++++++++++++++++++++++ libcockatrice_rng/CMakeLists.txt | 1 + oracle/CMakeLists.txt | 2 + servatrice/CMakeLists.txt | 3 ++ tests/oracle/CMakeLists.txt | 2 + 11 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 cmake/Coverage.cmake create mode 100644 gcovr.cfg diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 96dd10763..3166b7eb4 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && \ cmake \ file \ g++ \ + gcovr \ git \ libgl-dev \ liblzma-dev \ diff --git a/.ci/compile.sh b/.ci/compile.sh index de4fac004..606b9d201 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -9,14 +9,16 @@ # --server compiles servatrice # --test runs tests # --sanitize builds with AddressSanitizer and UndefinedBehaviorSanitizer, gcc/clang only +# --coverage instruments the test run for line coverage and prints a gcovr summary, gcc only + # --debug or --release sets the build type ie CMAKE_BUILD_TYPE # --ccache [] uses ccache and shows stats, optionally provide size # --evict-ccache runs ccache eviction based on given age after build # --dir sets the name of the build dir, default is "build" # --cmake-generator sets CMAKE_GENERATOR as used by cmake # --target-macos-version sets the min os version - only used for macOS builds -# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION -# (correspond to args: --debug/--release --install --package --suffix --server --test --ccache --dir ) +# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE +# (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir ) # exitcode: 1 for failure, 3 for invalid arguments # Read arguments @@ -62,6 +64,10 @@ while [[ $# != 0 ]]; do MAKE_SANITIZE=1 shift ;; + '--coverage') + MAKE_COVERAGE=1 + shift + ;; '--debug') BUILDTYPE="Debug" shift @@ -136,6 +142,15 @@ if [[ ! $BUILD_DIR ]]; then BUILD_DIR="build" fi mkdir -p "$BUILD_DIR" + +if [[ $MAKE_COVERAGE ]]; then + # Resolved while the working directory is still the source root. $0 is relative to the + # directory the script was invoked from, so after the cd below it no longer resolves, and + # under `set -e` the failed subshell would abort the build rather than just yielding an empty + # variable. Only the coverage report needs this, so only coverage pays for it. + SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +fi + cd "$BUILD_DIR" # Set minimum CMake Version @@ -160,6 +175,9 @@ else # into the shipped binaries (libasan/libubsan Depends in the DEB). flags+=("-DENABLE_SANITIZERS=OFF") fi +if [[ $MAKE_COVERAGE ]]; then + flags+=("-DENABLE_COVERAGE=ON") +fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") # PCH-aware caching is required or ccache refuses to cache any TU that @@ -341,6 +359,31 @@ if [[ $MAKE_TEST ]]; then echo "::endgroup::" fi +if [[ $MAKE_COVERAGE ]]; then + # gcovr is not a build dependency and is not available everywhere, so a missing one is reported + # and skipped rather than failing an otherwise green build. + echo "::group::Coverage report" + GCOVR="$(command -v gcovr || true)" + if [[ -z $GCOVR ]]; then + echo "gcovr not found; install it to produce a coverage report" + else + "$GCOVR" --version + # This script configures with `cmake ..`, so the build dir it was started from is the object + # directory and the source root is one level up. Both are passed explicitly: gcovr otherwise + # walks the current directory and trips over in-tree build directories left by other tooling, + # whose stale .gcno files cannot be resolved. --config is named too, so the filters apply no + # matter what directory gcovr happens to start from. + GCOVR_ARGS=("." --root "$SRC_ROOT" --object-directory "." --config "$SRC_ROOT/gcovr.cfg") + "$GCOVR" "${GCOVR_ARGS[@]}" --txt --decisions + "$GCOVR" "${GCOVR_ARGS[@]}" --html-details coverage-details.html --html-title "Cockatrice line coverage" + "$GCOVR" "${GCOVR_ARGS[@]}" --lcov -o coverage.lcov + "$GCOVR" "${GCOVR_ARGS[@]}" --cobertura-pretty -o coverage.xml + "$GCOVR" "${GCOVR_ARGS[@]}" --json-summary-pretty -o coverage-summary.json + echo "Coverage artifacts written to coverage-details.html, coverage.lcov, coverage.xml and coverage-summary.json" + fi + echo "::endgroup::" +fi + if [[ $MAKE_INSTALL ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" diff --git a/.ci/docker.sh b/.ci/docker.sh index 46112daaa..412ad6a1d 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -13,11 +13,17 @@ # --set-cache sets the location to cache the image or for ccache # # requires: docker -# uses env: NAME CACHE BUILD GET SAVE INTERACTIVE +# uses env: NAME IMAGE_SUFFIX CACHE BUILD GET SAVE INTERACTIVE # (correspond to args: --set-cache --build --get --save --interactive) # sets env: RUN CCACHE_DIR IMAGE_NAME RUN_ARGS RUN_OPTS BUILD_SCRIPT # exitcode: 1 for failure, 2 for missing dockerfile, 3 for invalid arguments # +# IMAGE_SUFFIX is optional and only appended to the image name, never to NAME, so that a matrix +# leg can have its own image and image cache without needing its own directory under .ci. Two legs +# building and tagging the same image concurrently would race on `docker build --tag`. +# It is deliberately not called SUFFIX: compile.sh already uses SUFFIX for output file names, and +# a job-level SUFFIX would be silently overridden by those per-step env blocks. +# # exported RUN function will run the BUILD_SCRIPT inside of the docker container. # note that the docker container will not inherit any environment variables! # @@ -81,7 +87,7 @@ if ! [[ $NAME ]]; then return 3 fi -export IMAGE_NAME="${project_name,,}_${NAME,,}" # lower case +export IMAGE_NAME="${project_name,,}_${NAME,,}${IMAGE_SUFFIX:+_${IMAGE_SUFFIX,,}}" # lower case docker_dir=".ci/$NAME" if ! [[ -r $docker_dir/Dockerfile ]]; then diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 57c2993f6..098aabc4f 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -149,17 +149,31 @@ jobs: # uninstrumented suite, so nothing is lost by instrumenting this one. sanitize: yes - name: ${{ matrix.distro }} ${{ matrix.version }} + - distro: Ubuntu + version: 26.04 + variant: cover # separate image and ccache, same distro + + # The one leg that runs instrumented for line coverage and publishes the report as + # a workflow artifact. It is a separate leg rather than a second flag on the + # sanitizer leg because the two instruments answer different questions: the + # sanitizer leg reports what the run did wrong, the coverage leg reports what the + # run never touched at all. Combining them would also slow both down. Packaging is + # skipped because the leg above already publishes the DEB for this distro. + coverage: yes + package: skip + + name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} timeout-minutes: 70 env: - CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache + CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} + IMAGE_SUFFIX: ${{ matrix.variant }} steps: - name: "Checkout" @@ -169,7 +183,7 @@ jobs: id: restore_ccache uses: actions/cache/restore@v6 with: - key: ccache-${{ matrix.distro }}${{ matrix.version }} + key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} path: ${{ env.CACHE }} - name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image" @@ -181,6 +195,7 @@ jobs: shell: bash env: sanitize: '${{ matrix.sanitize }}' + coverage: '${{ matrix.coverage }}' run: | source .ci/docker.sh args=() @@ -190,9 +205,32 @@ jobs: extra=() [[ $sanitize == yes ]] && extra+=(--sanitize) + [[ $coverage == yes ]] && extra+=(--coverage) RUN --server --debug --test "${extra[@]}" "${args[@]}" + # RUN bind-mounts the workspace into the container, so the report the build wrote into + # build/ is already on the host and no second container is needed to collect it. + - name: "Collect coverage report" + if: matrix.coverage == 'yes' + shell: bash + run: | + mkdir -p coverage + # --html-details writes one page per source file plus a stylesheet and script, and the + # index links to all of them by relative name. Copying the index alone would publish a + # report where every link 404s, so the whole family has to travel together. + cp build/coverage-details* coverage/ + cp build/coverage.lcov build/coverage.xml build/coverage-summary.json coverage/ + ls -la coverage/ + + - name: "Upload coverage artifact" + if: matrix.coverage == 'yes' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + if-no-files-found: warn + - name: "Build release package" id: build if: matrix.package != 'skip' diff --git a/CMakeLists.txt b/CMakeLists.txt index ef8866e3a..b272d4ad8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,8 @@ option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) option(TEST "Build tests" OFF) # Build with AddressSanitizer and UndefinedBehaviorSanitizer option(ENABLE_SANITIZERS "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +# Build instrumented for GCC line coverage, reported with gcovr +option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) @@ -213,9 +215,11 @@ else() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") endif() -# Always included, because it defines cockatrice_disable_sanitizers_for() for the vendored -# trees. It only touches the global flags when ENABLE_SANITIZERS is on. +# Always included, because they define cockatrice_disable_sanitizers_for() and +# cockatrice_disable_coverage_for() for the vendored trees. They only touch the global flags +# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on. include(Sanitizers) +include(Coverage) # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") diff --git a/cmake/Coverage.cmake b/cmake/Coverage.cmake new file mode 100644 index 000000000..0345a2d36 --- /dev/null +++ b/cmake/Coverage.cmake @@ -0,0 +1,75 @@ +# Line-coverage instrumentation opt-in, plus per-source opt-out +# Input: ENABLE_COVERAGE +# Defines: cockatrice_disable_coverage_for +# +# Included once from the root CMakeLists.txt, immediately after the per-compiler flag +# block, so add_compile_options() reaches every library and executable defined below. +# +# Note the name deliberately shadows CMake's own built-in Modules/Coverage.cmake. It resolves +# to this file only because the root CMakeLists.txt puts cmake/ at the front of +# CMAKE_MODULE_PATH; if that ever moves, include(Coverage) would silently pick up the built-in +# module, which only defines a `COVERAGE` custom target and touches no global flags, so +# instrumentation would quietly stop happening rather than erroring. + +include(CheckCXXSourceCompiles) + +# Opt a set of vendored sources out of instrumentation. +# +# cockatrice_disable_coverage_for( ...) +# +# Third-party code drags the total down with lines no test can ever reach, and a total that +# cannot move is not a useful signal. This is the same carve-out the sanitizer build uses. +# +# The sources must be named relative to the directory that compiles them. CMake resolves a +# source-file property against the directory scope the target was declared in, so calling +# this from the root with absolute paths silently does nothing. That is why the call sites +# live next to the add_library/add_executable that pull the vendored code in. +# +# SKIP_PRECOMPILE_HEADERS mirrors the sanitizer helper. The precompiled header carries the +# instrumentation flags, and mixing an instrumented PCH with an uninstrumented translation +# unit makes GCC reject it. +function(cockatrice_disable_coverage_for) + if(NOT ENABLE_COVERAGE) + return() + endif() + # There is no -fno-coverage: --coverage is shorthand for -fprofile-arcs -ftest-coverage, so both + # halves have to be negated. CC rejects "-fno-coverage" outright, which is how the vendored C + # files below hit it first. + set_source_files_properties( + ${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-profile-arcs;-fno-test-coverage" SKIP_PRECOMPILE_HEADERS ON + ) +endfunction() + +if(ENABLE_COVERAGE) + # Line coverage needs the compiler's own toolchain, so only GCC is supported. Clang's + # equivalent is llvm-cov, which reads a different intermediate format and would need a + # separate report path; rejecting it is better than emitting .gcno files that gcovr cannot + # read. + if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + message(FATAL_ERROR "ENABLE_COVERAGE requires GCC, but the compiler is ${CMAKE_CXX_COMPILER_ID}. " + "Clang coverage needs llvm-cov and is not wired up." + ) + endif() + + # Probe the way the flag will actually be used: compile AND link. A .gcno file comes from the + # compile step, but the runtime that writes .gcda files comes from the link step, so probing + # only the compile step would pass on a toolchain that cannot actually produce coverage. + set(_cockatrice_saved_required_flags "${CMAKE_REQUIRED_FLAGS}") + set(_cockatrice_saved_required_link_options "${CMAKE_REQUIRED_LINK_OPTIONS}") + set(CMAKE_REQUIRED_FLAGS "-O0 -g") + set(CMAKE_REQUIRED_LINK_OPTIONS -fprofile-arcs -ftest-coverage) + check_cxx_source_compiles("int main() { return 0; }" COCKATRICE_HAVE_COVERAGE_FLAGS) + set(CMAKE_REQUIRED_FLAGS "${_cockatrice_saved_required_flags}") + set(CMAKE_REQUIRED_LINK_OPTIONS "${_cockatrice_saved_required_link_options}") + + if(NOT COCKATRICE_HAVE_COVERAGE_FLAGS) + message(FATAL_ERROR "ENABLE_COVERAGE is on, but this toolchain rejected the coverage flags.") + endif() + + # -O0 keeps the line mapping honest: at higher optimisation levels the compiler merges and + # relocates lines, and the report describes code that no longer exists in that form. + add_compile_options(--coverage -O0 -g) + add_link_options(--coverage) + + message(STATUS "Coverage instrumentation enabled (--coverage). Report with gcovr over the build dir.") +endif() diff --git a/gcovr.cfg b/gcovr.cfg new file mode 100644 index 000000000..ac893b6a4 --- /dev/null +++ b/gcovr.cfg @@ -0,0 +1,63 @@ +# gcovr configuration for Cockatrice line coverage. +# +# Used by .ci/compile.sh --coverage and by hand: +# +# cmake -S . -B build/cover -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=1 -DTEST=1 -DENABLE_COVERAGE=ON +# cmake --build build/cover -j 24 +# ctest --test-dir build/cover -LE bench -j 24 +# gcovr build/cover --root . --object-directory build/cover --txt --decisions +# +# The point of most of this file is subtraction. A raw report is dominated by code no test can +# reach: vendored third-party trees, build directories left over from earlier experiments, and +# the client's main() and window classes, which have no headless test. Those all dilute the one +# number worth watching, so they are filtered out here rather than in every invocation. + +# root is deliberately not set here. This file is normally invoked from the build directory +# (compile.sh runs there, because it configures with `cmake ..`), so a `root` of `.` would +# resolve to the build tree and every filter below would exclude everything. compile.sh passes +# --root explicitly instead. + +# Our own code only. This keeps generated Qt MOC output, protobuf output and anything CMake +# built under the source tree out of the report. +filter = ^cockatrice/ +filter = ^libcockatrice_[^/]+/ +filter = ^servatrice/ +filter = ^oracle/ + +# The tests are the vehicle, not the subject. They are compiled with the same instrumentation, +# so leaving them in would add their near-perfect coverage to the numerator and quietly report a +# healthier project than we have: test code is only ever executed by the run that is measuring +# it. Helpers under tests/ are excluded on the same grounds. +exclude = ^tests/ + +# Vendored third-party code is not ours to cover and is never going to move the number. +exclude = ^vcpkg/ +exclude = ^doc/doxygen/theme/ +exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/ +exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$ +exclude = ^oracle/src/lzma/ +exclude = ^oracle/src/qt-json/ +exclude = ^oracle/src/zip/ +exclude = ^servatrice/src/smtp/ + +# Qt's own moc/pch/uic output is generated into the build tree, not the source tree, so the +# filters above already keep it out. These cover CLion's directories, which sit in-tree and are +# picked up by the object-directory walk. +exclude = .*cmake-build-.* +exclude = .*\.cache/ + +# Process entry points and the client's window chrome. Each of these needs a display, an +# interactive terminal, or a live database before it does anything a test could observe, so no +# headless run will ever move them. Left in, they contribute thousands of lines at 0% and drag +# the total down by a fixed amount that hides movement in the code we can actually test. The +# window header is excluded with its .cpp because gcovr attributes its inline template code +# there, which is where most of its line count lives. +exclude = ^cockatrice/src/main\.cpp$ +exclude = ^cockatrice/src/interface/window_main\. +exclude = ^oracle/src/main\.cpp$ +exclude = ^servatrice/src/main\.cpp$ + +exclude-throw-branches = yes +exclude-function-lines = yes + +print-summary = yes diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index 16beffcfe..6128e3f6e 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -13,6 +13,7 @@ add_library( # SFMT is vendored and its own headers trip UBSan's alignment and vptr checks. cockatrice_disable_sanitizers_for(libcockatrice/rng/sfmt/SFMT.c) +cockatrice_disable_coverage_for(libcockatrice/rng/sfmt/SFMT.c) target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 9b6863553..0df345812 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -81,6 +81,7 @@ if(ZLIB_FOUND) list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp) # minizip and zlib are vendored. cockatrice_disable_sanitizers_for(src/zip/unzip.cpp src/zip/zipglobal.cpp) + cockatrice_disable_coverage_for(src/zip/unzip.cpp src/zip/zipglobal.cpp) else() message(STATUS "Oracle: zlib not found; ZIP support disabled") endif() @@ -93,6 +94,7 @@ if(LIBLZMA_FOUND) list(APPEND oracle_SOURCES src/lzma/decompress.cpp) # The LZMA SDK is vendored. cockatrice_disable_sanitizers_for(src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(src/lzma/decompress.cpp) else() message(STATUS "Oracle: LibLZMA not found; xz support disabled") endif() diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index b176d8009..b45608d86 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -31,6 +31,9 @@ set(servatrice_RESOURCES servatrice.qrc) cockatrice_disable_sanitizers_for( src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp ) +cockatrice_disable_coverage_for( + src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp +) if(WIN32) set(servatrice_SOURCES ${servatrice_SOURCES} servatrice.rc) diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index c896c7960..6958673f7 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -26,6 +26,7 @@ if(ZLIB_FOUND) add_definitions("-DHAS_ZLIB") set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES}) + cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES}) set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) include_directories(${ZLIB_INCLUDE_DIRS}) else() @@ -37,6 +38,7 @@ if(LIBLZMA_FOUND) add_definitions("-DHAS_LZMA") list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) include_directories(${LIBLZMA_INCLUDE_DIRS}) else() From 315cbae345894b805a7f5a4ae8916c01feeb12a0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:07:30 +0200 Subject: [PATCH 008/247] [Build] Extend PCH to libraries and grow IWYU-driven header sets (#7456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executables precompile Qt since #7235, but the libcockatrice_* static libraries still reparse the full Qt Core stack in every TU. Measured over the tree after the IWYU pass: 380 MB of header parse in the libraries, 92% of it reachable from qtcore_pch.h. - Apply qtcore_pch.h to the 14 Qt Core static library targets (skips libcockatrice_rng: its SFMT.c is C and cannot use a C++ precompiled header). - Grow qtcore_pch.h with 25 QtCore headers that IWYU shows are re-parsed across many TUs and were not already reachable from the existing list (JSON/QUuid cluster, QSettings, item models, threading, XmlStream, QTemporaryDir, ...). - Grow qtwidgets_pch.h with 30 QtGui/QtWidgets headers (dialog cluster, item views, QImageReader, ...). Only stable Qt headers go into the PCH, so it rarely invalidates. Test executables stay PCH-less: 1-3 TUs per target does not amortize the extra precompile. Co-authored-by: Lukas Brübach --- cmake/pch/qtcore_pch.h | 31 +++++++++++++++++++ cmake/pch/qtwidgets_pch.h | 29 +++++++++++++++++ libcockatrice_card/CMakeLists.txt | 2 ++ libcockatrice_deck_list/CMakeLists.txt | 2 ++ libcockatrice_filters/CMakeLists.txt | 2 ++ libcockatrice_interfaces/CMakeLists.txt | 2 ++ .../models/database/CMakeLists.txt | 2 ++ .../models/deck_list/CMakeLists.txt | 2 ++ .../network/client/abstract/CMakeLists.txt | 2 ++ .../network/client/local/CMakeLists.txt | 2 ++ .../network/client/remote/CMakeLists.txt | 2 ++ .../network/server/local/CMakeLists.txt | 2 ++ .../network/server/remote/CMakeLists.txt | 2 ++ libcockatrice_protocol/CMakeLists.txt | 2 ++ libcockatrice_settings/CMakeLists.txt | 2 ++ libcockatrice_utility/CMakeLists.txt | 2 ++ 16 files changed, 88 insertions(+) diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h index cc3dd12ee..176f2939c 100644 --- a/cmake/pch/qtcore_pch.h +++ b/cmake/pch/qtcore_pch.h @@ -3,22 +3,53 @@ * * Safe for every target that links Qt Core, including the headless * Servatrice binary. Keep this header free of any widget/gui types. + * + * Candidates are chosen from IWYU include statistics: a header earns a + * spot when it is not already reachable from the rest of this list and + * enough translation units re-parse it. Qt headers are stable across + * builds, so the precompiled header rarely invalidates. */ #include #include +#include +#include +#include #include #include +#include +#include #include +#include #include +#include +#include +#include +#include +#include #include #include #include +#include #include +#include +#include #include +#include +#include #include +#include +#include +#include +#include #include +#include #include #include +#include +#include #include +#include #include +#include +#include diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h index 2c63f450e..030bec69c 100644 --- a/cmake/pch/qtwidgets_pch.h +++ b/cmake/pch/qtwidgets_pch.h @@ -4,27 +4,56 @@ * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and * Network layers that virtually every client translation unit re-parses. * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI). + * + * Candidates are chosen from IWYU include statistics: a header earns a + * spot when it is not already reachable from the rest of this list and + * enough translation units re-parse it. Qt headers are stable across + * builds, so the precompiled header rarely invalidates. */ #include "qtcore_pch.h" #include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include #include +#include +#include +#include +#include #include +#include +#include #include #include +#include #include #include +#include #include #include #include +#include #include #include +#include +#include +#include #include +#include +#include #include +#include #include +#include #include diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index 15388ecfc..b3583522c 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -60,3 +60,5 @@ target_link_libraries( PUBLIC libcockatrice_utility PUBLIC ${QT_CORE_MODULE} ) + +target_precompile_headers(libcockatrice_card PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 0c487466c..e61a5702c 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -41,3 +41,5 @@ target_link_libraries( libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility ${QT_CORE_MODULE} ) + +target_precompile_headers(libcockatrice_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 905533df8..2d45b8e5b 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -18,3 +18,5 @@ add_dependencies(libcockatrice_filters libcockatrice_card) target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(libcockatrice_filters PUBLIC libcockatrice_card ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_filters PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index f53c41807..3b17569b1 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -31,3 +31,5 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_interfaces PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index 840f14b08..a73e36dbb 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -29,3 +29,5 @@ add_library( target_include_directories(libcockatrice_models_database PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(libcockatrice_models_database PUBLIC libcockatrice_card libcockatrice_filters ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_models_database PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index a6ab2a204..324bed102 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -16,3 +16,5 @@ target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT target_link_libraries( libcockatrice_models_deck_list PUBLIC libcockatrice_card libcockatrice_deck_list ${QT_CORE_MODULE} ) + +target_precompile_headers(libcockatrice_models_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 6fba8d629..53ad524b4 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -19,3 +19,5 @@ target_link_libraries( PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol libcockatrice_network_server_remote ) + +target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 2ac12e1fe..08c12b7f8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -19,3 +19,5 @@ target_link_libraries( PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_network_client_abstract ) + +target_precompile_headers(libcockatrice_network_client_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index cb68d0c37..c32ca0aa8 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -19,3 +19,5 @@ target_link_libraries( PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_network_client_abstract libcockatrice_interfaces libcockatrice_utility libcockatrice_protocol ) + +target_precompile_headers(libcockatrice_network_client_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 494ae1294..020753d7f 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -15,3 +15,5 @@ add_dependencies(libcockatrice_network_server_local libcockatrice_protocol) target_include_directories(libcockatrice_network_server_local PUBLIC .) target_link_libraries(libcockatrice_network_server_local PUBLIC ${COCKATRICE_QT_MODULES} libcockatrice_protocol) + +target_precompile_headers(libcockatrice_network_server_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index e11a962d1..5840f80c0 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -62,3 +62,5 @@ target_link_libraries( libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng libcockatrice_deck_list ${COCKATRICE_QT_MODULES} ) + +target_precompile_headers(libcockatrice_network_server_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_protocol/CMakeLists.txt b/libcockatrice_protocol/CMakeLists.txt index 7dc0a0360..55184045a 100644 --- a/libcockatrice_protocol/CMakeLists.txt +++ b/libcockatrice_protocol/CMakeLists.txt @@ -24,3 +24,5 @@ target_include_directories( libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR} # points to the generated headers ) + +target_precompile_headers(libcockatrice_protocol PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index 80a465b6d..5a2fa42a9 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -71,3 +71,5 @@ target_include_directories( ) target_link_libraries(libcockatrice_settings PUBLIC libcockatrice_card libcockatrice_utility ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_settings PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index d986214d7..5e6fa986d 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -49,3 +49,5 @@ target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Cr set(ORACLE_LIBS) include_directories(${${COCKATRICE_QT_VERSION_NAME}Core_INCLUDE_DIRS}) + +target_precompile_headers(libcockatrice_utility PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") From 4a010bd17f619a73b47e71f89d66d101ded2d4b7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:28:45 +0200 Subject: [PATCH 009/247] [Report] Add report reopen action for closed reports (#7442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Command_ReportReopen moderator command (extension 1206) with server handler that reopens resolved/dismissed reports, resets resolution state and notified, and records the reopening note in the audit log - Reopen... button in the report queue action bar, gated to resolved and dismissed reports, with optional note prompt and tooltip - Success confirmations for Assign, Resolve/Dismiss and Reopen now survive the list refresh via a pending status message instead of being clobbered - Dispatch/auth coverage in server_moderator_role_test Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_report.cpp | 57 ++++++- .../src/interface/widgets/tabs/tab_report.h | 4 + .../libcockatrice/protocol/pb/CMakeLists.txt | 1 + .../protocol/pb/command_report_reopen.proto | 10 ++ .../protocol/pb/moderator_commands.proto | 1 + servatrice/src/serversocketinterface.cpp | 39 +++++ servatrice/src/serversocketinterface.h | 2 + tests/CMakeLists.txt | 12 ++ tests/server_moderator_role_test.cpp | 156 ++++++++++++++++++ 9 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_reopen.proto create mode 100644 tests/server_moderator_role_test.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 5f158e9b1..5fe656f44 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -233,12 +234,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T resolveButton = new QPushButton; resolveWithNoteButton = new QPushButton; dismissButton = new QPushButton; + reopenButton = new QPushButton; viewReplayButton = new QPushButton; joinGameButton = new QPushButton; connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); }); connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); }); connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); }); + connect(reopenButton, &QPushButton::clicked, this, &TabReport::reopenReport); connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay); connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); @@ -249,6 +252,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T actionBar->addWidget(resolveButton); actionBar->addWidget(resolveWithNoteButton); actionBar->addWidget(dismissButton); + actionBar->addWidget(reopenButton); actionBar->addSpacing(20); actionBar->addWidget(viewReplayButton); actionBar->addWidget(joinGameButton); @@ -315,6 +319,8 @@ void TabReport::retranslateUi() resolveButton->setText(tr("Resolve")); resolveWithNoteButton->setText(tr("Resolve with note...")); dismissButton->setText(tr("Dismiss...")); + reopenButton->setText(tr("Reopen...")); + reopenButton->setToolTip(tr("Available for resolved or dismissed reports.")); viewReplayButton->setText(tr("View Replay")); joinGameButton->setText(tr("Join Game")); } @@ -346,6 +352,7 @@ void TabReport::reportListResponse(const Response &response) refreshButton->setEnabled(true); if (response.response_code() != Response::RespOk) { + pendingStatusMessage.clear(); statusLabel->setText(tr("Failed to load reports.")); return; } @@ -359,6 +366,11 @@ void TabReport::reportListResponse(const Response &response) applyFilters(); updateStats(); + if (!pendingStatusMessage.isEmpty()) { + statusLabel->setText(pendingStatusMessage); + pendingStatusMessage.clear(); + } + if (selectedReportIdBeforeRefresh >= 0) { bool found = false; for (int row = 0; row < table->rowCount(); ++row) { @@ -579,6 +591,7 @@ void TabReport::updateActionStates() resolveButton->setEnabled(status == "open" || status == "assigned"); resolveWithNoteButton->setEnabled(status == "open" || status == "assigned"); dismissButton->setEnabled(status == "open" || status == "assigned"); + reopenButton->setEnabled(status == "resolved" || status == "dismissed"); const bool hasGameId = report.game_id() > 0; const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0; @@ -592,6 +605,7 @@ void TabReport::setActionsEnabled(bool enabled) resolveButton->setEnabled(enabled); resolveWithNoteButton->setEnabled(enabled); dismissButton->setEnabled(enabled); + reopenButton->setEnabled(enabled); viewReplayButton->setEnabled(false); joinGameButton->setEnabled(false); commentButton->setEnabled(enabled); @@ -645,7 +659,7 @@ void TabReport::assignReport() void TabReport::assignResponse(const Response &response) { if (response.response_code() == Response::RespOk) { - statusLabel->setText(tr("Assigned.")); + pendingStatusMessage = tr("Assigned."); refreshList(); } else { statusLabel->setText(tr("Assignment failed.")); @@ -689,7 +703,46 @@ void TabReport::resolveReport(bool dismissed, bool promptNote) void TabReport::resolveResponse(const Response &response) { if (response.response_code() == Response::RespOk) { - statusLabel->setText(tr("Done.")); + pendingStatusMessage = tr("Done."); + refreshList(); + } else { + statusLabel->setText(tr("Action failed.")); + updateActionStates(); + } +} + +void TabReport::reopenReport() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + bool ok; + QString note = QInputDialog::getText(this, tr("Reopen Report"), tr("Reopening note (optional):"), QLineEdit::Normal, + QString(), &ok); + if (!ok) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Reopening...")); + + Command_ReportReopen cmd; + cmd.set_report_id(reportId); + if (!note.isEmpty()) { + cmd.set_reopen_note(note.toStdString()); + } + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::reopenResponse); + client->sendCommand(pend); +} + +void TabReport::reopenResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + pendingStatusMessage = tr("Reopened."); refreshList(); } else { statusLabel->setText(tr("Action failed.")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 645f949de..6ea8199ce 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -56,6 +56,8 @@ private slots: void reportDetailsResponse(const Response &response); void requestStats(); void statsResponse(const Response &response); + void reopenReport(); + void reopenResponse(const Response &response); private: int selectedReportId() const; @@ -87,6 +89,7 @@ private: QPushButton *resolveButton; QPushButton *resolveWithNoteButton; QPushButton *dismissButton; + QPushButton *reopenButton; QPushButton *viewReplayButton; QPushButton *joinGameButton; QLabel *statusLabel; @@ -108,6 +111,7 @@ private: QLabel *userContextNotesLabel; QLabel *userContextRecentReportsLabel; QString lastRequestedUser; + QString pendingStatusMessage; QGroupBox *statsGroup; QLabel *statsTotalLabel; QLabel *statsTrendLabel; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index bc4814d5e..4e4f0e190 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -55,6 +55,7 @@ set(PROTO_FILES command_report_details.proto command_report_list.proto command_report_my_list.proto + command_report_reopen.proto command_report_resolve.proto command_report_stats.proto command_report_user_info.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_reopen.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_reopen.proto new file mode 100644 index 000000000..1f9c707e8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_reopen.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportReopen { + extend ModeratorCommand { + optional Command_ReportReopen ext = 1206; + } + required int32 report_id = 1; + optional string reopen_note = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 4f1e80c27..5298d3dad 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -26,6 +26,7 @@ message ModeratorCommand { REPLAY_DOWNLOAD_BY_GAME_ID = 1203; REPORT_USER_INFO = 1204; REPORT_STATS = 1205; + REPORT_REOPEN = 1206; } extensions 100 to max; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index d46a9b0ee..8fa070ce7 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -110,6 +110,7 @@ #include #include #include +#include #include #include #include @@ -434,6 +435,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdReportAssign(cmd.GetExtension(Command_ReportAssign::ext), rc); case ModeratorCommand::REPORT_RESOLVE: return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); + case ModeratorCommand::REPORT_REOPEN: + return cmdReportReopen(cmd.GetExtension(Command_ReportReopen::ext), rc); case ModeratorCommand::VIEWLOG_HISTORY: return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true); case ModeratorCommand::GRANT_REPLAY_ACCESS: @@ -2791,6 +2794,42 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReportReopen(const Command_ReportReopen &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString note = textFromStdString(cmd.reopen_note()); + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = 'open', resolution_note = NULL, " + "resolution_time = NULL, resolved_by = NULL, assigned_to = NULL, " + "notified = 0 " + "WHERE id = :id AND status IN ('resolved', 'dismissed')"); + + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + return Response::RespInvalidData; + } + + QString auditDetails = QString("Report #%1 reopened").arg(reportId); + if (!note.isEmpty()) { + auditDetails += QString(". Reason: %1").arg(note.left(200)); + } + sqlInterface->addAuditRecord(QString::number(reportId), this->getAddress(), + QString::fromStdString(userInfo->clientid()), "REPORT_REOPENED", auditDetails, true); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 27708ed27..7de742c66 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -96,6 +96,7 @@ class Command_ReportAssign; class Command_ReportDetails; class Command_ReportList; class Command_ReportMyList; +class Command_ReportReopen; class Command_ReportResolve; class Command_ReportStats; class Command_ReportUserInfo; @@ -181,6 +182,7 @@ private: Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &); Response::ResponseCode cmdReportResolve(const Command_ReportResolve &cmd, ResponseContainer &); + Response::ResponseCode cmdReportReopen(const Command_ReportReopen &cmd, ResponseContainer &); Response::ResponseCode cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc); Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c48e6c957..22dbedb98 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -228,6 +228,18 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_moderator_role_test + NO_SANITIZE + SOURCES + server_moderator_role_test.cpp + LIBS + libcockatrice_network + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME server_game_join_test diff --git a/tests/server_moderator_role_test.cpp b/tests/server_moderator_role_test.cpp new file mode 100644 index 000000000..2517a2d86 --- /dev/null +++ b/tests/server_moderator_role_test.cpp @@ -0,0 +1,156 @@ +/** @file server_moderator_role_test.cpp + * @brief Tests for the moderator staff role authorization and dispatch. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The server_remote library references the global RNG, which is normally +// defined by the servatrice/client executable main(). Provide a stub so the +// unit test can link against it. +RNG_Abstract *rng = nullptr; + +namespace +{ + +class TestModeratorHandler : public Server_ProtocolHandler +{ +public: + explicit TestModeratorHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + // Buffer the last response code sent to the client so tests can assert on + // the outcome of processCommandContainer(). + Response::ResponseCode lastResponseCode = Response::RespNothing; + int dispatchCount = 0; + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } + + Response::ResponseCode + processExtendedModeratorCommand(int cmdType, const ModeratorCommand &, ResponseContainer &) override + { + ++dispatchCount; + // Fail closed for anything not explicitly handled. + if (cmdType != ModeratorCommand::REPORT_REOPEN) { + return Response::RespFunctionNotAllowed; + } + return Response::RespOk; + } +}; + +class ModeratorRoleTest : public ::testing::Test +{ +protected: + Server server; + TestModeratorHandler handler{&server}; + + void setUserLevel(uint32_t level) + { + ServerInfo_User user; + user.set_user_level(level); + handler.setUserInfo(user); + } +}; + +TEST_F(ModeratorRoleTest, RejectsWhenNotLoggedIn) +{ + CommandContainer cont; + cont.add_moderator_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(ModeratorRoleTest, RejectsPlainUser) +{ + setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + + CommandContainer cont; + cont.add_moderator_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(ModeratorRoleTest, DispatchesToModeratorCommandForModerator) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + ModeratorCommand *cmd = cont.add_moderator_command(); + cmd->MutableExtension(Command_ReportReopen::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(ModeratorRoleTest, DispatchesToModeratorCommandForAdmin) +{ + setUserLevel(ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin); + + CommandContainer cont; + ModeratorCommand *cmd = cont.add_moderator_command(); + cmd->MutableExtension(Command_ReportReopen::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(ModeratorRoleTest, RejectsDeveloperThatIsNotModerator) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + cont.add_moderator_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(ModeratorRoleTest, FailClosedForUnknownModeratorCommand) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + cont.add_moderator_command(); // no extension set -> getPbExtension() returns -1 + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_EQ(handler.dispatchCount, 1); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 74a6acde917830c100a89ee831e4d2557673b32d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:29:09 +0200 Subject: [PATCH 010/247] [Report] Surface resolution audit info in report details (#7443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolved_by_name to ServerInfo_Report and join resolved_by users in the report list, my-list and details queries - Render resolution note and resolved-at/by in the report detail pane instead of duplicating the description into the chat log context Co-authored-by: Lukas Brübach --- .../widgets/utility/report_utils.cpp | 21 ++++++- .../protocol/pb/serverinfo_report.proto | 1 + servatrice/src/serversocketinterface.cpp | 63 +++++++++++-------- 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index a581c9837..d7fc083c6 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -100,10 +100,27 @@ void renderReportDetails(QTextEdit *chatLogEdit, const QString &moderatorPrefix, const QString &nonModeratorPrefix) { + QString details; + if (report.has_resolution_note() && !report.resolution_note().empty()) { + details = QObject::tr("Resolution note: %1").arg(QString::fromStdString(report.resolution_note())); + } + if (report.has_resolution_time()) { + QString resolvedBy = QObject::tr("unknown"); + if (report.has_resolved_by_name() && !report.resolved_by_name().empty()) { + resolvedBy = QString::fromStdString(report.resolved_by_name()); + } + if (!details.isEmpty()) { + details += "\n"; + } + details += QObject::tr("Resolved at %1 by %2").arg(formatReportTime(report.resolution_time()), resolvedBy); + } + if (report.has_chat_log() && !report.chat_log().empty()) { - chatLogEdit->setPlainText(QString::fromStdString(report.chat_log())); + const QString chatLog = QString::fromStdString(report.chat_log()); + chatLogEdit->setPlainText( + details.isEmpty() ? chatLog : details + "\n\n" + QObject::tr("=== Chat Log ===") + "\n" + chatLog); } else { - chatLogEdit->clear(); + chatLogEdit->setPlainText(details); } commentsEdit->clear(); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto index a145fd855..e67b1f361 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto @@ -33,4 +33,5 @@ message ServerInfo_Report { optional string chat_log = 14; optional string resolution_note = 15; + optional string resolved_by_name = 16; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 8fa070ce7..177e945c2 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2015,11 +2015,12 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " "r.category, r.description, r.created_at, r.status, " - "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.room_id, " "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " "AS replay_id " "FROM {prefix}_reports r " - "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "; + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id "; if (unresolvedOnly) { queryStr += "WHERE r.status = 'open' OR r.status = 'assigned' "; @@ -2060,13 +2061,16 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman if (!query->value(9).isNull()) { info->set_assigned_mod_name(query->value(9).toString().toStdString()); } - if (!query->value(10).isNull()) { - info->set_room_id(query->value(10).toInt()); + info->set_resolved_by_name(query->value(10).toString().toStdString()); } if (!query->value(11).isNull()) { - info->set_replay_id(query->value(11).toInt()); + info->set_room_id(query->value(11).toInt()); + } + + if (!query->value(12).isNull()) { + info->set_replay_id(query->value(12).toInt()); } } @@ -2740,11 +2744,12 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " "r.category, r.description, r.created_at, r.status, " - "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.room_id, " "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " "AS replay_id " "FROM {prefix}_reports r " "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id " "WHERE r.reporter_id = :reporter_id " "ORDER BY r.created_at DESC LIMIT 200"; @@ -2780,13 +2785,16 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm if (!query->value(9).isNull()) { info->set_assigned_mod_name(query->value(9).toString().toStdString()); } - if (!query->value(10).isNull()) { - info->set_room_id(query->value(10).toInt()); + info->set_resolved_by_name(query->value(10).toString().toStdString()); } if (!query->value(11).isNull()) { - info->set_replay_id(query->value(11).toInt()); + info->set_room_id(query->value(11).toInt()); + } + + if (!query->value(12).isNull()) { + info->set_replay_id(query->value(12).toInt()); } } @@ -2845,17 +2853,19 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Com // Columns: 0=id, 1=reporter_id, 2=reporter_name, 3=reported_user_name, 4=game_id, // 5=category, 6=description, 7=created_at, 8=status, - // 9=resolution_note, 10=assigned_mod_name, 11=chat_log, - // 12=room_id, 13=resolution_time, 14=replay_id - QString queryStr = "SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, " - "r.category, r.description, r.created_at, r.status, " - "r.resolution_note, u.name AS assigned_mod_name, r.chat_log, r.room_id, " - "r.resolution_time, " - "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " - "AS replay_id " - "FROM {prefix}_reports r " - "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " - "WHERE r.id = :id"; + // 9=resolution_note, 10=assigned_mod_name, 11=resolved_by_name, + // 12=chat_log, 13=room_id, 14=resolution_time, 15=replay_id + QString queryStr = + "SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.chat_log, r.room_id, " + "r.resolution_time, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id " + "WHERE r.id = :id"; QSqlQuery *query = sqlInterface->prepareQuery(queryStr); query->bindValue(":id", reportId); @@ -2894,21 +2904,24 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Com if (!query->value(10).isNull()) { info->set_assigned_mod_name(query->value(10).toString().toStdString()); } - if (!query->value(11).isNull()) { - info->set_chat_log(query->value(11).toString().toStdString()); + info->set_resolved_by_name(query->value(11).toString().toStdString()); } if (!query->value(12).isNull()) { - info->set_room_id(query->value(12).toInt()); + info->set_chat_log(query->value(12).toString().toStdString()); } if (!query->value(13).isNull()) { - info->set_resolution_time(query->value(13).toDateTime().toSecsSinceEpoch()); + info->set_room_id(query->value(13).toInt()); } if (!query->value(14).isNull()) { - info->set_replay_id(query->value(14).toInt()); + info->set_resolution_time(query->value(14).toDateTime().toSecsSinceEpoch()); + } + + if (!query->value(15).isNull()) { + info->set_replay_id(query->value(15).toInt()); } QSqlQuery *commentQuery = From 38acddeedefe2fdbdd172e7d7a5c637465904074 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:33:47 +0200 Subject: [PATCH 011/247] [Report] Add context menu to report queue rows (#7444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Report] Add report reopen action for closed reports - New Command_ReportReopen moderator command (extension 1206) with server handler that reopens resolved/dismissed reports, resets resolution state and notified, and records the reopening note in the audit log - Reopen... button in the report queue action bar, gated to resolved and dismissed reports, with optional note prompt and tooltip - Success confirmations for Assign, Resolve/Dismiss and Reopen now survive the list refresh via a pending status message instead of being clobbered - Dispatch/auth coverage in server_moderator_role_test * [Report] Surface resolution audit info in report details - Add resolved_by_name to ServerInfo_Report and join resolved_by users in the report list, my-list and details queries - Render resolution note and resolved-at/by in the report detail pane instead of duplicating the description into the chat log context * [Report] Add context menu to report queue rows - Open the standard UserContextMenu on right-click/Menu key for the reported user, selecting the row first so the details panel matches the menu target - Carry the reported user's level in ServerInfo_Report so the menu shows the same actions as the user list, with an IsUser fallback for deleted or unregistered users - Wire the Private chat action through TabReport to TabSupervisor and retranslate the menu on live language switches * [Server] Fix reported_user_level column indices in report queries The SELECT lists resolved_by_name at index 10, shifting room_id to 11, replay_id to 12, reported_user_id to 13 and reported_user_admin to 14. The code read 12/13, so 'user exists' meant 'a replay exists' and the admin flags came from the user id (e.g. id 5 -> admin+judge; users without a replay reported as unregistered, hiding registered-only actions). Fixed in cmdReportList and cmdReportMyList, plus the column comment. --------- Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_report.cpp | 37 +++++++++++++++++ .../src/interface/widgets/tabs/tab_report.h | 5 +++ .../interface/widgets/tabs/tab_supervisor.cpp | 1 + .../protocol/pb/serverinfo_report.proto | 2 + servatrice/src/serversocketinterface.cpp | 40 ++++++++++++++++--- 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 5fe656f44..3a31f9b52 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -1,5 +1,6 @@ #include "tab_report.h" +#include "../server/user/user_context_menu.h" #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -121,6 +123,10 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents); connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); + table->setContextMenuPolicy(Qt::CustomContextMenu); + connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu); + userContextMenu = new UserContextMenu(tabSupervisor, this); + connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog); descGroup = new QGroupBox; descriptionEdit = new QTextEdit; @@ -276,6 +282,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T void TabReport::retranslateUi() { + userContextMenu->retranslateUi(); searchEdit->setPlaceholderText(tr("Search by username, category...")); statusFilter->clear(); statusFilter->addItem(tr("All Statuses"), ""); @@ -506,6 +513,36 @@ void TabReport::onSelectionChanged() updateActionStates(); } +void TabReport::showTableContextMenu(const QPoint &pos) +{ + QTableWidgetItem *clickedItem = table->itemAt(pos); + if (!clickedItem || clickedItem->row() < 0) { + return; + } + + const int row = clickedItem->row(); + const QTableWidgetItem *reportedItem = table->item(row, COL_REPORTED); + const QTableWidgetItem *idItem = table->item(row, COL_ID); + if (!reportedItem || !idItem || reportedItem->text().isEmpty()) { + return; + } + + int userLevel = ServerInfo_User::IsUser; + const int reportId = idItem->data(Qt::UserRole).toInt(); + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == reportId) { + if (r.reported_user_level() != 0) { + userLevel = r.reported_user_level(); + } + break; + } + } + + table->selectRow(row); + userContextMenu->showContextMenu(table->viewport()->mapToGlobal(pos), reportedItem->text(), + UserLevelFlags(userLevel)); +} + void TabReport::loadReportDetails(int reportId) { detailsRequestedReportId = reportId; diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 6ea8199ce..ea686c239 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -4,6 +4,7 @@ #include "tab.h" #include +#include #include #include #include @@ -22,6 +23,7 @@ class QTimer; class GameReplay; class Response; class TabSupervisor; +class UserContextMenu; class TabReport : public Tab { @@ -37,6 +39,7 @@ public: signals: void openReplay(GameReplay *replay); void requestJoinGame(int gameId, int roomId); + void openMessageDialog(const QString &userName, bool focus); private slots: void refreshList(); @@ -46,6 +49,7 @@ private slots: void resolveReport(bool dismissed, bool promptNote); void resolveResponse(const Response &response); void onSelectionChanged(); + void showTableContextMenu(const QPoint &pos); void viewReplay(); void viewReplayResponse(const Response &response); void joinGame(); @@ -94,6 +98,7 @@ private: QPushButton *joinGameButton; QLabel *statusLabel; QTimer *refreshTimer; + UserContextMenu *userContextMenu; QGroupBox *userContextGroup; QLabel *userContextName; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 70014ec47..2352d7d7b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -919,6 +919,7 @@ void TabSupervisor::openTabReport() myAddTab(tabReport, aTabReport); connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay); connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame); + connect(tabReport, &TabReport::openMessageDialog, this, &TabSupervisor::addMessageTab); connect(tabReport, &QObject::destroyed, this, [this] { tabReport = nullptr; aTabReport->setChecked(false); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto index e67b1f361..3b624cc6d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto @@ -34,4 +34,6 @@ message ServerInfo_Report { optional string resolution_note = 15; optional string resolved_by_name = 16; + + optional int32 reported_user_level = 17; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 177e945c2..5f5d1ced5 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -1986,6 +1986,29 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com return Response::RespOk; } +namespace +{ +int reportUserLevel(bool userExists, int adminFlags) +{ + int userLevel = ServerInfo_User::IsUser; + if (userExists) { + userLevel |= ServerInfo_User::IsRegistered; + if (adminFlags & 1) { + userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator; + } else if (adminFlags & 2) { + userLevel |= ServerInfo_User::IsModerator; + } + if (adminFlags & 4) { + userLevel |= ServerInfo_User::IsJudge; + } + if (adminFlags & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } + } + return userLevel; +} +} // namespace + Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Command_ReportList &cmd, ResponseContainer &rc) { @@ -1999,8 +2022,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman // Columns: 0=id, 1=reporter_name, 2=reported_user_name, 3=game_id, // 4=category, 5=description, 6=created_at, 7=status, - // 8=resolution_note, 9=assigned_mod_name, - // 10=room_id, 11=replay_id + // 8=resolution_note, 9=assigned_mod_name, 10=resolved_by_name, + // 11=room_id, 12=replay_id, + // 13=reported_user_id, 14=reported_user_admin QString whereClause; if (unresolvedOnly) { whereClause = "WHERE r.status = 'open' OR r.status = 'assigned' "; @@ -2017,10 +2041,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman "r.category, r.description, r.created_at, r.status, " "r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.room_id, " "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " - "AS replay_id " + "AS replay_id, u2.id AS reported_user_id, u2.admin AS reported_user_admin " "FROM {prefix}_reports r " "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " - "LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id "; + "LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id " + "LEFT JOIN {prefix}_users u2 ON u2.name = r.reported_user_name "; if (unresolvedOnly) { queryStr += "WHERE r.status = 'open' OR r.status = 'assigned' "; @@ -2072,6 +2097,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman if (!query->value(12).isNull()) { info->set_replay_id(query->value(12).toInt()); } + + info->set_reported_user_level(reportUserLevel(!query->value(13).isNull(), query->value(14).toInt())); } rc.setResponseExtension(re); @@ -2746,10 +2773,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm "r.category, r.description, r.created_at, r.status, " "r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.room_id, " "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " - "AS replay_id " + "AS replay_id, u2.id AS reported_user_id, u2.admin AS reported_user_admin " "FROM {prefix}_reports r " "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " "LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id " + "LEFT JOIN {prefix}_users u2 ON u2.name = r.reported_user_name " "WHERE r.reporter_id = :reporter_id " "ORDER BY r.created_at DESC LIMIT 200"; @@ -2796,6 +2824,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm if (!query->value(12).isNull()) { info->set_replay_id(query->value(12).toInt()); } + + info->set_reported_user_level(reportUserLevel(!query->value(13).isNull(), query->value(14).toInt())); } rc.setResponseExtension(re); From 07eb9f0103b1ac8f353f337d376e8f606d73e87a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:33:48 +0200 Subject: [PATCH 012/247] [Report] Prompt for a note on Resolve and drop the redundant option (#7445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the third close option (Resolve with note...) so closing a report is one of two clear actions - Make Resolve always prompt for the optional resolution note, matching Dismiss, and rename the button to Resolve... - Halve the resolveReport signature now that every caller prompts Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_report.cpp | 29 +++++++------------ .../src/interface/widgets/tabs/tab_report.h | 3 +- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 3a31f9b52..9018e2477 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -238,15 +238,13 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T assignButton = new QPushButton; resolveButton = new QPushButton; - resolveWithNoteButton = new QPushButton; dismissButton = new QPushButton; reopenButton = new QPushButton; viewReplayButton = new QPushButton; joinGameButton = new QPushButton; connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); - connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); }); - connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); }); - connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); }); + connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false); }); + connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true); }); connect(reopenButton, &QPushButton::clicked, this, &TabReport::reopenReport); connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay); connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); @@ -256,7 +254,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T auto *actionBar = new QHBoxLayout; actionBar->addWidget(assignButton); actionBar->addWidget(resolveButton); - actionBar->addWidget(resolveWithNoteButton); actionBar->addWidget(dismissButton); actionBar->addWidget(reopenButton); actionBar->addSpacing(20); @@ -323,8 +320,7 @@ void TabReport::retranslateUi() statsGroup->setTitle(tr("Report Statistics")); assignButton->setText(tr("Assign to Me")); - resolveButton->setText(tr("Resolve")); - resolveWithNoteButton->setText(tr("Resolve with note...")); + resolveButton->setText(tr("Resolve...")); dismissButton->setText(tr("Dismiss...")); reopenButton->setText(tr("Reopen...")); reopenButton->setToolTip(tr("Available for resolved or dismissed reports.")); @@ -626,7 +622,6 @@ void TabReport::updateActionStates() const QString status = QString::fromStdString(report.status()); assignButton->setEnabled(status == "open"); resolveButton->setEnabled(status == "open" || status == "assigned"); - resolveWithNoteButton->setEnabled(status == "open" || status == "assigned"); dismissButton->setEnabled(status == "open" || status == "assigned"); reopenButton->setEnabled(status == "resolved" || status == "dismissed"); @@ -640,7 +635,6 @@ void TabReport::setActionsEnabled(bool enabled) { assignButton->setEnabled(enabled); resolveButton->setEnabled(enabled); - resolveWithNoteButton->setEnabled(enabled); dismissButton->setEnabled(enabled); reopenButton->setEnabled(enabled); viewReplayButton->setEnabled(false); @@ -704,22 +698,19 @@ void TabReport::assignResponse(const Response &response) } } -void TabReport::resolveReport(bool dismissed, bool promptNote) +void TabReport::resolveReport(bool dismissed) { const int reportId = selectedReportId(); if (reportId < 0) { return; } - QString note; - if (promptNote) { - bool ok; - note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"), - dismissed ? tr("Optional note:") : tr("Resolution note (optional):"), - QLineEdit::Normal, QString(), &ok); - if (!ok) { - return; - } + bool ok; + QString note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"), + dismissed ? tr("Optional note:") : tr("Resolution note (optional):"), + QLineEdit::Normal, QString(), &ok); + if (!ok) { + return; } setActionsEnabled(false); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index ea686c239..32ebe1a3f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -46,7 +46,7 @@ private slots: void reportListResponse(const Response &response); void assignReport(); void assignResponse(const Response &response); - void resolveReport(bool dismissed, bool promptNote); + void resolveReport(bool dismissed); void resolveResponse(const Response &response); void onSelectionChanged(); void showTableContextMenu(const QPoint &pos); @@ -91,7 +91,6 @@ private: QPushButton *commentButton; QPushButton *assignButton; QPushButton *resolveButton; - QPushButton *resolveWithNoteButton; QPushButton *dismissButton; QPushButton *reopenButton; QPushButton *viewReplayButton; From f83a61979fb33f8a9ad3f7d19d0ee2c56aebfcba Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:34:49 +0200 Subject: [PATCH 013/247] [Installer] Uninstaller deletes only the installed payload, never the whole tree (#7472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 48 ++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 4c55c00b3..b6125238f 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -304,10 +304,11 @@ SetShellVarContext all ; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller ; normally copies itself to %TEMP% and runs from there so that it can delete its ; own directory, and the process we started exits at once. ExecWait then returns -; before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" executes -; while the section below is still writing the new payload. _?= disables the copy -; and makes ExecWait block until the uninstall finishes. It must be the last -; argument and must not be quoted, even when the path contains spaces. +; before the uninstall has run, and the temp copy keeps deleting files - from +; 3.1.0 and 3.1.1 on disk a blanket "RMDir /r $INSTDIR" - while the section +; below is still writing the new payload. _?= disables the copy and makes +; ExecWait block until the uninstall finishes. It must be the last argument +; and must not be quoted, even when the path contains spaces. ; ; --- 32-bit uninstall --- SetRegView 32 @@ -1107,9 +1108,14 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" ; upgrade portable mode !insertmacro CockLog "section: portable upgrade purge" !insertmacro CockLogListing "before-portable-purge" "*.dll" + ; plugins and translations are payload and are rewritten by the payload + ; below, so removing them here is safe. sounds and themes are payload + ; too - the default packs are rewritten below as well - but a portable + ; user's custom sounds and themes live in exactly those folders, so a + ; recursive purge here would delete user content along with the defaults. + ; Stale default files from an older release are overwritten or replaced + ; by the payload, never by a purge. RMDir /r "$INSTDIR\plugins" - RMDir /r "$INSTDIR\sounds" - RMDir /r "$INSTDIR\themes" RMDir /r "$INSTDIR\translations" Delete "$INSTDIR\uninstall.exe" Delete "$INSTDIR\cockatrice.exe" @@ -1263,16 +1269,32 @@ Section "un.Application" UnSecApplication SetShellVarContext all StrCpy $R1 0 - ; Record whether this was a portable install before the directory (and the - ; portable.dat marker inside it) is removed below. + ; Record whether this was a portable install before portable.dat is + ; removed below. IfFileExists "$INSTDIR\portable.dat" 0 +2 StrCpy $R1 1 - ; Remove the entire application directory so any file that is not part of - ; the installed payload (e.g. build-tree artifacts such as *.dir folders, - ; *_autogen and *.tlog files from a build) cannot survive between an - ; uninstall and a fresh reinstall. - RMDir /r "$INSTDIR" + ; Remove only what the installer wrote. The two lists below are generated + ; by CPack from the staged install tree: one Delete per installed file and + ; one non-recursive RMDir per installed directory, children first. + ; Anything the user put in $INSTDIR - decks, pictures, portable data + ; folders - is not on those lists and survives, and $INSTDIR itself is + ; only removed when nothing else is left in it. An earlier version of this + ; section ran a blanket "RMDir /r $INSTDIR" to keep build-tree artifacts + ; out of a fresh reinstall; that also deleted user decks whenever a user + ; had pointed their data folders into the install directory, so payload + ; purity may never be bought at that price again. +@CPACK_NSIS_DELETE_FILES@ +@CPACK_NSIS_DELETE_DIRECTORIES@ + + ; Written by the installer itself, so absent from the staged payload + ; above; qdebug.txt is app-generated log output next to the executables. + Delete "$INSTDIR\uninstall.exe" + Delete "$INSTDIR\portable.dat" + Delete "$INSTDIR\qdebug.txt" + + ; Succeeds only when no foreign files remain. + RMDir "$INSTDIR" RMDir /r "$SMPROGRAMS\Cockatrice" From 57a6a0d9cbb26d82f187f4dd076cce881fb21a1e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:34:49 +0200 Subject: [PATCH 014/247] [Client] Warn when data paths point inside the program directory (#7473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/dialogs/path_safety_warning.cpp | 34 +++++++++++++ .../widgets/dialogs/path_safety_warning.h | 25 +++++++++ .../deck_editor_settings_page.cpp | 4 ++ .../settings_page/general_settings_page.cpp | 22 ++++++++ cockatrice/src/interface/window_main.cpp | 33 ++++++++++++ cockatrice/src/interface/window_main.h | 3 ++ .../libcockatrice/settings/paths_settings.cpp | 51 +++++++++++++++++++ .../libcockatrice/settings/paths_settings.h | 24 ++++++++- tests/settings/settings_defaults_test.cpp | 51 +++++++++++++++++++ 10 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/path_safety_warning.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/path_safety_warning.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 32b81cf61..63ce7d1af 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -59,6 +59,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp + src/interface/widgets/dialogs/path_safety_warning.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp src/interface/widgets/deck_share/deck_share_utils.cpp src/interface/widgets/deck_share/shared_deck_preview_widget.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/path_safety_warning.cpp b/cockatrice/src/interface/widgets/dialogs/path_safety_warning.cpp new file mode 100644 index 000000000..d8eabd72d --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/path_safety_warning.cpp @@ -0,0 +1,34 @@ +#include "path_safety_warning.h" + +#include "../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include + +class QWidget; + +bool PathSafetyWarning::confirmOutsideProgramDir(QWidget *parent, const QString &path) +{ + if (SettingsCache::instance().getIsPortableBuild()) { + return true; + } + + if (!PathsSettings::isInsideDir(path, QCoreApplication::applicationDirPath())) { + return true; + } + + QMessageBox box(QMessageBox::Warning, QObject::tr("Path inside the program directory"), + QObject::tr("'%1' is inside the Cockatrice program directory.\n\n" + "Files stored there can be deleted by an update or by uninstalling " + "Cockatrice, so this change was not applied. Choose a location outside " + "the program directory.") + .arg(path), + QMessageBox::Ok, parent); + box.setTextFormat(Qt::PlainText); + box.exec(); + + return false; +} diff --git a/cockatrice/src/interface/widgets/dialogs/path_safety_warning.h b/cockatrice/src/interface/widgets/dialogs/path_safety_warning.h new file mode 100644 index 000000000..058df3e4b --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/path_safety_warning.h @@ -0,0 +1,25 @@ +#ifndef COCKATRICE_PATH_SAFETY_WARNING_H +#define COCKATRICE_PATH_SAFETY_WARNING_H + +class QString; +class QWidget; + +namespace PathSafetyWarning +{ + +/** + * @brief Warns when the user tries to configure a data path inside the program directory. + * + * Shows a message box explaining that files kept there can be deleted by an + * update or by uninstalling Cockatrice. Portable builds are always exempt: + * their data lives beside the executables by design. + * + * @param parent The parent widget + * @param path The path the user is trying to configure + * @return true when the path is outside the program directory and safe to apply + */ +bool confirmOutsideProgramDir(QWidget *parent, const QString &path); + +} // namespace PathSafetyWarning + +#endif // COCKATRICE_PATH_SAFETY_WARNING_H diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 2020b2031..31178ba4c 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../pixel_map_generator.h" +#include "../dialogs/path_safety_warning.h" #include "update/card_spoiler/spoiler_background_updater.h" #include @@ -363,6 +364,9 @@ void DeckEditorSettingsPage::spoilerPathButtonClicked() if (lsPath.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, lsPath + "/spoiler.xml")) { + return; + } mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml"); SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml"); 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 73a0f7be2..fa8938b32 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -1,6 +1,7 @@ #include "general_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../dialogs/path_safety_warning.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../main.h" #include "../server/user/user_info_connection.h" @@ -378,6 +379,9 @@ void GeneralSettingsPage::deckPathButtonClicked() if (path.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) { + return; + } deckPathEdit->setText(path); SettingsCache::instance().paths().setDeckPath(path); @@ -389,6 +393,9 @@ void GeneralSettingsPage::filtersPathButtonClicked() if (path.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) { + return; + } filtersPathEdit->setText(path); SettingsCache::instance().paths().setFiltersPath(path); @@ -400,6 +407,9 @@ void GeneralSettingsPage::replaysPathButtonClicked() if (path.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) { + return; + } replaysPathEdit->setText(path); SettingsCache::instance().paths().setReplaysPath(path); @@ -411,6 +421,9 @@ void GeneralSettingsPage::picsPathButtonClicked() if (path.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) { + return; + } picsPathEdit->setText(path); SettingsCache::instance().paths().setPicsPath(path); @@ -422,6 +435,9 @@ void GeneralSettingsPage::cardDatabasePathButtonClicked() if (path.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) { + return; + } cardDatabasePathEdit->setText(path); SettingsCache::instance().paths().setCardDatabasePath(path); @@ -433,6 +449,9 @@ void GeneralSettingsPage::customCardDatabaseButtonClicked() if (path.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) { + return; + } customCardDatabasePathEdit->setText(path); SettingsCache::instance().paths().setCustomCardDatabasePath(path); @@ -444,6 +463,9 @@ void GeneralSettingsPage::tokenDatabasePathButtonClicked() if (path.isEmpty()) { return; } + if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) { + return; + } tokenDatabasePathEdit->setText(path); SettingsCache::instance().paths().setTokenDatabasePath(path); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 189585d84..4f363181e 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -58,7 +58,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -603,8 +605,39 @@ MainWindow::MainWindow(QWidget *parent) QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } +void MainWindow::warnAboutDataPathsInProgramDir() +{ + auto &paths = SettingsCache::instance().paths(); + if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) { + return; + } + + const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath()); + if (offenders.isEmpty()) { + return; + } + + QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"), + tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n" + "Cockatrice keeps its own data outside the program directory by default. Files " + "stored inside it can be deleted by an update or by uninstalling Cockatrice. " + "Change them in the Settings dialog - most are on the General page - to keep " + "them safe.") + .arg(offenders.join(QLatin1Char('\n'))), + QMessageBox::Ok, this); + box.setTextFormat(Qt::PlainText); + auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); + box.setCheckBox(dontShowAgain); + box.exec(); + if (dontShowAgain->isChecked()) { + paths.setAppDirWarningAcknowledged(true); + } +} + void MainWindow::startupConfigCheck() { + warnAboutDataPathsInProgramDir(); + const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; // checkUnknownSets() is intentionally deferred from the card database load diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index bf68ed72a..6b1186d3c 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -149,6 +149,9 @@ private: void runFirstRunWizard(bool firstRun = false); + /** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */ + void warnAboutDataPathsInProgramDir(); + inline QString getCardUpdaterBinaryName() { return "oracle"; diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp index 5f5725395..41ccf6460 100644 --- a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp @@ -2,6 +2,8 @@ #include "libcockatrice/settings/settings_manager.h" +#include +#include #include class QObject; @@ -121,3 +123,52 @@ void PathsSettings::setSpoilerDatabasePath(const QString &_spoilerDatabasePath) setValue(_spoilerDatabasePath, "spoilerDatabase"); emit cardDatabasePathChanged(); } + +QStringList PathsSettings::pathsInsideDir(const QString &baseDir) const +{ + QStringList offenders; + const auto check = [&offenders, &baseDir](const QString &path) { + if (!path.isEmpty() && isInsideDir(path, baseDir)) { + offenders.append(QDir::cleanPath(path)); + } + }; + + check(getDeckPath()); + check(getFiltersPath()); + check(getReplaysPath()); + check(getPicsPath()); + check(getCustomPicsPath()); + check(getThemesPath()); + check(getCardDatabasePath()); + check(getCustomCardDatabasePath()); + check(getTokenDatabasePath()); + check(getSpoilerCardDatabasePath()); + check(getRedirectCachePath()); + + offenders.removeDuplicates(); + return offenders; +} + +bool PathsSettings::isInsideDir(const QString &path, const QString &baseDir) +{ +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + // Windows and default macOS volumes are case-insensitive filesystems. + constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseInsensitive; +#else + constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive; +#endif + const QString base = QDir::cleanPath(baseDir); + const QString cleaned = QDir::cleanPath(path); + const QString prefix = base.endsWith(QLatin1Char('/')) ? base : base + QLatin1Char('/'); + return cleaned == base || cleaned.startsWith(prefix, caseSensitivity); +} + +bool PathsSettings::getAppDirWarningAcknowledged() const +{ + return getValue("appDirWarningAcknowledged").toBool(); +} + +void PathsSettings::setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged) +{ + setValue(_appDirWarningAcknowledged, "appDirWarningAcknowledged"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.h b/libcockatrice_settings/libcockatrice/settings/paths_settings.h index 399337bbb..fa00428e3 100644 --- a/libcockatrice_settings/libcockatrice/settings/paths_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.h @@ -4,6 +4,7 @@ #include "settings_manager.h" #include +#include #include #include @@ -27,6 +28,24 @@ public: [[nodiscard]] QString getSpoilerCardDatabasePath() const override; [[nodiscard]] QString getRedirectCachePath() const override; + /** + * @brief Returns the configured paths that resolve inside baseDir. + * + * Comparison is lexical (QDir::cleanPath); symlinks are not resolved, so a + * path that only reaches baseDir through a symlink is not reported. + */ + [[nodiscard]] QStringList pathsInsideDir(const QString &baseDir) const; + + /** + * @brief Whether path resolves inside baseDir, as a single-path check. + * + * A path counts as inside when it equals baseDir or is nested below it; + * a sibling sharing the name prefix (baseDir + "2") does not. + */ + [[nodiscard]] static bool isInsideDir(const QString &path, const QString &baseDir); + + [[nodiscard]] bool getAppDirWarningAcknowledged() const; + void setDeckPath(const QString &_deckPath); void setFiltersPath(const QString &_filtersPath); void setReplaysPath(const QString &_replaysPath); @@ -37,14 +56,17 @@ public: void setCustomCardDatabasePath(const QString &_customCardDatabasePath); void setTokenDatabasePath(const QString &_tokenDatabasePath); void setSpoilerDatabasePath(const QString &_spoilerDatabasePath); + void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged); signals: void cardDatabasePathChanged(); void picsPathChanged(); void themeChanged(); -private: +public: explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr); + +private: PathsSettings(const PathsSettings & /*other*/); }; diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 65e41af76..2ba9f3fbb 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -1,6 +1,7 @@ #include "libcockatrice/card/card_localization.h" #include "gtest/gtest.h" +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -695,6 +697,55 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_SetAndGet) ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), false); } +TEST_F(SettingsDefaultsTest, Paths_AppDirWarningAcknowledged_Default) +{ + PathsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getAppDirWarningAcknowledged(), false); +} + +TEST_F(SettingsDefaultsTest, Paths_AppDirWarningAcknowledged_SetAndGet) +{ + PathsSettings s(settingsPath, nullptr); + s.setAppDirWarningAcknowledged(true); + ASSERT_EQ(s.getAppDirWarningAcknowledged(), true); +} + +TEST_F(SettingsDefaultsTest, Paths_PathsInsideDir_DetectsNestedAndIgnoresSiblings) +{ + PathsSettings s(settingsPath, nullptr); + const QString base = QDir::cleanPath(settingsPath); + + s.setDeckPath(base + "/decks/"); + s.setPicsPath(base + "/pics"); + s.setReplaysPath(base + "2"); // sibling sharing the name prefix, not nested + + const QStringList offenders = s.pathsInsideDir(base); + ASSERT_EQ(offenders.size(), 2); + ASSERT_TRUE(offenders.contains(base + "/decks")); + ASSERT_TRUE(offenders.contains(base + "/pics")); +} + +TEST_F(SettingsDefaultsTest, Paths_PathsInsideDir_EmptyWhenNothingMatches) +{ + PathsSettings s(settingsPath, nullptr); + s.setDeckPath("/somewhere/else/decks"); + ASSERT_TRUE(s.pathsInsideDir(QDir::cleanPath(settingsPath)).isEmpty()); +} + +TEST_F(SettingsDefaultsTest, Paths_PathsInsideDir_MatchesExactBaseAndNormalizesDots) +{ + PathsSettings s(settingsPath, nullptr); + const QString base = QDir::cleanPath(settingsPath); + + s.setCustomPicsPath(base); // exactly the base directory + s.setThemesPath(base + "/pics/../decks"); // needs cleanPath normalization + + const QStringList offenders = s.pathsInsideDir(base); + ASSERT_EQ(offenders.size(), 2); + ASSERT_TRUE(offenders.contains(base)); + ASSERT_TRUE(offenders.contains(base + "/decks")); +} + } // namespace int main(int argc, char **argv) From 5d06bb903d8016b495d4be2d0d149b3f965ccb77 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:35:10 +0200 Subject: [PATCH 015/247] [Chat] Fix card completion after an @ mention (#7469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open [[ card link now takes precedence over a space-preceded @ mention, so typing an @ before a card no longer shadows the card completer. Co-authored-by: Lukas Brübach --- .../interface/widgets/utility/line_edit_completer.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp index 056887f1a..d692fb41b 100644 --- a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp +++ b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp @@ -130,10 +130,19 @@ void LineEditCompleter::keyPressEvent(QKeyEvent *event) CompleterInfo *active = nullptr; QString prefix; + // An open "[[" card link takes precedence over a mention: while the cursor is + // inside it, an earlier "@" must not shadow the card completer. + const int openCardPos = textValue.lastIndexOf("[[", cursorPos - 1); + const int closeCardPos = openCardPos == -1 ? -1 : textValue.indexOf("]]", openCardPos + 2); + const bool insideCardLink = openCardPos != -1 && (closeCardPos == -1 || closeCardPos >= cursorPos); + for (auto &info : completers) { bool triggered = false; switch (info.trigger) { case CompleterTrigger::Mention: { + if (insideCardLink) { + break; + } int triggerPos = textValue.lastIndexOf("@", cursorPos - 1); if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) { triggered = true; From 4c5a5e3e55404f7c66eb9886c78c9d719e4aacbe Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:36:11 +0200 Subject: [PATCH 016/247] [Game] Invalidate view when hiding replaced card menu (#7453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Game] Invalidate view when hiding replaced card menu Hide any visible card menu before replacing and invalidate viewport. * [Game] Fix viewport update compile error in setCardMenu QWidget::update has no QRectF overload, and sceneRect is in scene coordinates rather than viewport coordinates anyway, so invalidate the whole viewport instead. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/interface/widgets/tabs/tab_game.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 9a6862b2c..4cf5919ba 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -1060,6 +1060,12 @@ void TabGame::setCardMenu(CardMenu *menu) return; } + if (aCardMenu->menu() && aCardMenu->menu()->isVisible()) { + aCardMenu->menu()->hide(); + if (gameView && gameView->viewport()) { + gameView->viewport()->update(); + } + } if (menu) { aCardMenu->setMenu(menu); } else { From 4feb31bd1a4c829294339b74dc6a5ef57631da2a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:53:26 +0200 Subject: [PATCH 017/247] [Game] Show loaded deck messages to players joining a game (#7268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show loaded deck messages to players joining a game (#552) The "Player X has loaded a deck" message is only broadcast when a deck is selected, so players and spectators who join a game afterwards never see it. On game join, replay a deck-select event context privately to the joining participant for every player that already has a deck loaded, reusing the existing Event_PlayerPropertiesChanged + Context_DeckSelect mechanism so no protocol version bump is needed. - Server_AbstractPlayer: expose the sideboard-locked state - Server_Game: synthesize deck-select contexts in createGameJoinedEvent * [Server] Skip deck replay on rejoin and after start --------- Co-authored-by: Lukas Brübach --- .../remote/game/server_abstract_player.h | 4 ++ .../server/remote/game/server_game.cpp | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h index eff5034b4..ae20955bf 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h @@ -56,6 +56,10 @@ public: { return deck; } + bool getSideboardLocked() const + { + return sideboardLocked; + } bool getReadyStart() const { return readyStart; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 330d473b2..b1863c91e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -61,6 +61,8 @@ #include #include #include +#include +#include #include #include #include @@ -825,6 +827,48 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti } rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1)); + + // A newly joined player never saw the "Player X has loaded a deck" messages + // (they are only broadcast when a deck is selected), so replay a deck-select + // context for every player that already has a deck loaded. It is delivered + // privately to the joining participant, whose client renders it as a log + // entry in the same way as a fresh deck selection. Skipped on reconnect + // (resuming) — the player already saw these — and once the game has started, + // when the message is no longer useful and would only spam the log. + if (!resuming && !gameStarted) { + for (auto *participant : participants.values()) { + if (participant == joiningParticipant) { + continue; + } + auto *deckOwner = qobject_cast(participant); + if (deckOwner == nullptr) { + continue; + } + const DeckList *deckList = deckOwner->getDeckList(); + if (deckList == nullptr) { + continue; + } + + Event_PlayerPropertiesChanged event; + event.mutable_player_properties()->set_sideboard_locked(deckOwner->getSideboardLocked()); + event.mutable_player_properties()->set_deck_hash(deckList->getDeckHash().toStdString()); + + Context_DeckSelect deckSelect; + deckSelect.set_deck_hash(deckList->getDeckHash().toStdString()); + deckSelect.set_sideboard_size(deckList->getSideboardSize()); + if (getShareDecklistsOnLoad()) { + deckSelect.set_deck_list(deckList->writeToString_Native().toStdString()); + } + + GameEventContext context; + context.GetReflection() + ->MutableMessage(&context, deckSelect.GetDescriptor()->FindExtensionByName("ext")) + ->CopyFrom(deckSelect); + + rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, + prepareGameEvent(event, deckOwner->getPlayerId(), &context)); + } + } } void Server_Game::sendGameEventContainer(GameEventContainer *cont, From 1a4d89ff8f28d28af3a4f49e1f4e1552e1caeaae Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:54:03 +0200 Subject: [PATCH 018/247] [Game] Don't alert game tab for spectator activity (#1848) (#7263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spectator join/leave events were triggering the game tab's attention indicator (exclamation mark and taskbar alert) for players who often leave the tab open while waiting for an opponent. This made spectator activity a false alarm. Only emit the user event when an actual player joins or leaves, keeping spectator joins/leaves quiet. Co-authored-by: Lukas Brübach --- cockatrice/src/game/game_event_handler.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index b86f661bb..7b08af47c 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -277,8 +277,6 @@ void GameEventHandler::eventSpectatorLeave(const Event_Leave &event, emit spectatorLeft(eventPlayerId); game->getPlayerManager()->removeSpectator(eventPlayerId); - - emitUserEvent(); } void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event, @@ -465,9 +463,9 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/, PlayerLogic *newPlayer = game->getPlayerManager()->addPlayer(playerId, playerInfo.user_info()); emit logJoinPlayer(newPlayer); emit playerJoined(playerInfo); - } - emitUserEvent(); + emitUserEvent(); + } } QString GameEventHandler::getLeaveReason(Event_Leave::LeaveReason reason) From 89687096423af74ac7729823142428960bb48d3e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:59:32 +0200 Subject: [PATCH 019/247] [SMTP] Add reliable email delivery with retry and database-backed queue (#7240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [SMTP] Add reliable email delivery with retry and database-backed queue Rework the SMTP client from fire-and-forget sending into a persistent queue with exponential backoff and per-message retry budget, backed by the existing database rows as the durable source of truth. - Keep pending emails in an in-memory queue with attempts/backoff tracking; enqueue without deleting database rows, and only remove the row once delivery is confirmed via the mailDelivered signal. - Add exponential backoff (doubling, capped at 30 min) between delivery attempts, bounded by new smtp/maxretries and smtp/retrydelay settings. - Track connection-layer failures (connect/auth/encrypt) with their own backoff; route failures to recordFailed/recordConnectionFailure so undeliverable mail eventually drops from the queue instead of retrying forever. - Treat rejected recipients/senders as permanent failures. - Rebuild the SMTP buffer from the authoritative queue before each send via a new QxtSmtp::reset() so reconnects never duplicate deliveries. - Start the idle timeout after authentication, not on TCP connect, so slow SSL/STARTTLS handshakes are not cut short. * [SMTP] Fix retry accounting, backoff reset, and token dedupe --------- Co-authored-by: Lukas Brübach --- servatrice/servatrice.ini.example | 10 + servatrice/src/servatrice.cpp | 104 ++++-- servatrice/src/servatrice.h | 8 + servatrice/src/smtp/qxtsmtp.cpp | 32 ++ servatrice/src/smtp/qxtsmtp.h | 2 + servatrice/src/smtpclient.cpp | 532 ++++++++++++++++++++++++------ servatrice/src/smtpclient.h | 100 +++++- 7 files changed, 655 insertions(+), 133 deletions(-) diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index c625705da..3ee52dd21 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -250,6 +250,16 @@ email=root@localhost ; Sender email name name="Cockatrice server" +; Maximum number of attempts to deliver a given e-mail before giving up. +; If the smtp server is unreachable or repeatedly rejects a message, the server +; will retry with an exponentially growing delay up to this many times, then drop +; the message from the queue and log the failure. Default: 5 +maxretries=5 + +; Initial retry delay in seconds between delivery attempts. Doubles with each +; failed attempt (capped at 30 minutes). Default: 60 +retrydelay=60 + ; Email subject subject="Cockatrice server account activation token" diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 0329a31fd..9ec9ef5bc 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -69,6 +70,8 @@ #include #include +inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice"); + Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, int _numberPools, const QSqlDatabase &_sqlDatabase, @@ -335,6 +338,11 @@ bool Servatrice::initServer() servatriceDatabaseInterface->clearSessionTables(); } + // Connect the SMTP client's delivery-confirmation signals to the database cleanup handlers. + // Emails are removed from the queue tables only after delivery is confirmed (see statusUpdate()). + connect(smtpClient, &SmtpClient::mailDelivered, this, &Servatrice::onEmailDelivered); + connect(smtpClient, &SmtpClient::mailPermanentlyFailed, this, &Servatrice::onEmailPermanentlyFailed); + if (getRoomsMethodString() == "sql") { QSqlQuery *query = servatriceDatabaseInterface->prepareQuery( "select id, name, descr, permissionlevel, privlevel, auto_join, join_message, chat_history_size from " @@ -737,41 +745,36 @@ void Servatrice::statusUpdate() return; } - auto *queryDelete = - servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name"); - + // Rows are intentionally NOT deleted here: the durable source of truth for a pending + // activation e-mail is the database row, which is only removed once delivery has been + // confirmed by the SMTP client (see onEmailDelivered). This makes sending resilient to + // transient SMTP failures and server restarts. while (servDbSelQuery->next()) { const QString userName = servDbSelQuery->value(0).toString(); const auto emailAddress = EmailParser::getParsedEmailAddress(servDbSelQuery->value(1).toString()); const QString token = servDbSelQuery->value(2).toString(); - if (smtpClient->enqueueActivationTokenMail(userName, emailAddress, token)) { - queryDelete->bindValue(":name", userName); - servatriceDatabaseInterface->execSqlQuery(queryDelete); - } + smtpClient->enqueueActivationTokenMail(userName, emailAddress, token); } } if (getEnableForgotPassword()) { auto *forgotPwQuery = servatriceDatabaseInterface->prepareQuery( - "select a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on a.name " - "= b.name where a.emailed = 0"); + "select a.id, a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on " + "a.name = b.name where a.emailed = 0"); if (!servatriceDatabaseInterface->execSqlQuery(forgotPwQuery)) { return; } - QSqlQuery *queryDelete = servatriceDatabaseInterface->prepareQuery( - "update {prefix}_forgot_password set emailed = 1 where name = :name"); - + // Rows are intentionally left with emailed = 0 here; they are marked emailed only once + // delivery is confirmed by the SMTP client (see onEmailDelivered). while (forgotPwQuery->next()) { - const QString userName = forgotPwQuery->value(0).toString(); - const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(1).toString()); - const QString token = forgotPwQuery->value(2).toString(); + const qint64 rowId = forgotPwQuery->value(0).toLongLong(); + const QString userName = forgotPwQuery->value(1).toString(); + const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(2).toString()); + const QString token = forgotPwQuery->value(3).toString(); - if (smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token)) { - queryDelete->bindValue(":name", userName); - servatriceDatabaseInterface->execSqlQuery(queryDelete); - } + smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token, rowId); } } @@ -779,6 +782,69 @@ void Servatrice::statusUpdate() } } +void Servatrice::onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId) +{ + if (cleanupDatabaseForEmail(userName, type, token, rowId)) { + qCDebug(ServatriceLog) << "E-mail delivered to" << userName; + } else { + qCWarning(ServatriceLog) << "Failed to clean up database row for delivered e-mail to" << userName + << "- row may be re-enqueued on next status update"; + } +} + +void Servatrice::onEmailPermanentlyFailed(const QString &userName, + EmailType type, + FailureReason reason, + const QString &token, + qint64 rowId) +{ + switch (reason) { + case FailureReason::RetryExhausted: + qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed after max retries"; + break; + case FailureReason::RecipientRejected: + qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: recipient rejected"; + break; + case FailureReason::SenderRejected: + qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: sender rejected"; + break; + } + + if (!cleanupDatabaseForEmail(userName, type, token, rowId)) { + qCWarning(ServatriceLog) << "Failed to clean up database row for permanently failed e-mail to" << userName + << "- row may be re-enqueued on next status update"; + } +} + +bool Servatrice::cleanupDatabaseForEmail(const QString &userName, EmailType type, const QString &token, qint64 rowId) +{ + Q_UNUSED(token); + switch (type) { + case EmailType::Activation: { + QSqlQuery *query = + servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name"); + query->bindValue(":name", userName); + return servatriceDatabaseInterface->execSqlQuery(query); + } + case EmailType::ForgotPassword: { + // Scope the update to the row that was actually sent: the table + // holds one row per request, and marking by name would also mark + // rows for newer tokens that were never delivered. + if (rowId >= 0) { + QSqlQuery *query = servatriceDatabaseInterface->prepareQuery( + "update {prefix}_forgot_password set emailed = 1 where id = :id"); + query->bindValue(":id", rowId); + return servatriceDatabaseInterface->execSqlQuery(query); + } + QSqlQuery *query = servatriceDatabaseInterface->prepareQuery( + "update {prefix}_forgot_password set emailed = 1 where name = :name"); + query->bindValue(":name", userName); + return servatriceDatabaseInterface->execSqlQuery(query); + } + } + return false; +} + SessionEvent *Servatrice::makeShutdownEvent() const { Event_ServerShutdown event; diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 242d445ad..205a4f04d 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -21,6 +21,7 @@ #define SERVATRICE_H #include "metrics_registry.h" +#include "smtpclient.h" #include #include @@ -181,6 +182,7 @@ private: QMap serverRequiredFeatureList; QString officialWarnings; Servatrice_DatabaseInterface *servatriceDatabaseInterface; + bool cleanupDatabaseForEmail(const QString &userName, EmailType type, const QString &token, qint64 rowId); int serverId; int uptime; QMutex txBytesMutex, rxBytesMutex; @@ -231,6 +233,12 @@ public slots: void scheduleShutdown(const QString &reason, int minutes); void updateLoginMessage(); void setRequiredFeatures(const QString &featureList); + void onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId); + void onEmailPermanentlyFailed(const QString &userName, + EmailType type, + FailureReason reason, + const QString &token, + qint64 rowId); public: explicit Servatrice(QObject *parent = nullptr); diff --git a/servatrice/src/smtp/qxtsmtp.cpp b/servatrice/src/smtp/qxtsmtp.cpp index c81e0955c..a4da649f7 100644 --- a/servatrice/src/smtp/qxtsmtp.cpp +++ b/servatrice/src/smtp/qxtsmtp.cpp @@ -94,6 +94,38 @@ int QxtSmtp::pendingMessages() const return qxt_d().pending.count(); } +/*! + * \brief Drops all queued (not yet transmitted) messages without sending them. + * + * Any message still awaiting delivery is discarded. This is used by callers that + * keep their own authoritative queue and wish to rebuild the SMTP buffer from scratch + * (e.g. after a reconnect) without risking duplicates. + */ +void QxtSmtp::clearPending() +{ + qxt_d().pending.clear(); +} + +/*! + * \brief Clears the outgoing queue and resets the SMTP state machine. + * + * Unlike clearPending(), this also resets the internal state to Disconnected so + * that subsequent send() calls will not fire sendNext() prematurely on a + * not-yet-connected socket. Use this when rebuilding the queue from scratch + * before a fresh connectToHost() / connectToSecureHost() cycle. + */ +void QxtSmtp::reset() +{ + qxt_d().pending.clear(); + qxt_d().extensions.clear(); + qxt_d().buffer.clear(); + qxt_d().recipients.clear(); + qxt_d().rcptNumber = 0; + qxt_d().rcptAck = 0; + qxt_d().mailAck = false; + qxt_d().state = QxtSmtpPrivate::Disconnected; +} + QTcpSocket *QxtSmtp::socket() const { return qxt_d().socket; diff --git a/servatrice/src/smtp/qxtsmtp.h b/servatrice/src/smtp/qxtsmtp.h index 747e22df9..d264d1047 100644 --- a/servatrice/src/smtp/qxtsmtp.h +++ b/servatrice/src/smtp/qxtsmtp.h @@ -66,6 +66,8 @@ public: int send(const QxtMailMessage& message); int pendingMessages() const; + void clearPending(); + void reset(); QTcpSocket* socket() const; void connectToHost(const QString& hostName, quint16 port = 25); diff --git a/servatrice/src/smtpclient.cpp b/servatrice/src/smtpclient.cpp index a45588b8b..82824e496 100644 --- a/servatrice/src/smtpclient.cpp +++ b/servatrice/src/smtpclient.cpp @@ -7,17 +7,56 @@ #include #include #include +#include #include #include +#include #include #include #include #include +static const qint64 DEFAULT_MAX_RETRIES = 5; +static const qint64 DEFAULT_RETRY_DELAY = 60; +static const qint64 MAX_RETRY_DELAY = 1800; // 30 minutes +static const qint64 IDLE_TIMEOUT_MS = 60000; // 60 seconds +// Maximum bit-shift exponent used in the exponential back-off formula. +// With a base delay of 60 s, shift 10 gives 60 * 1024 ≈ 17 h before the +// MAX_RETRY_DELAY cap is applied, preventing unbounded intermediate values. +static const int MAX_BACKOFF_SHIFT = 10; + +inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client") + +static QString pendingKey(const QString &userName, EmailType type, const QString &token) +{ + const char *typeName = (type == EmailType::Activation) ? "activation" : "forgotpassword"; + // The token is part of the key: a regenerated token (forgot-password issues a + // fresh one per request) must not be suppressed by a stale pending entry. + return userName + '/' + typeName + '/' + token; +} + +// True only for permanent 5xx replies. Anything else (4xx greylisting/rate +// limiting, or an unparsable reply) must be retried, never treated as final: +// destroying the durable queue row on a transient failure loses the mail. +static bool isPermanentSmtpReply(const QByteArray &msg) +{ + for (char c : msg) { + if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '-') { + continue; + } + return c == '5'; + } + return false; +} + SmtpClient::SmtpClient(QObject *parent) : QObject(parent) { smtp = new QxtSmtp(this); + idleTimer = new QTimer(this); + idleTimer->setSingleShot(true); + connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleTimeout())); + connect(smtp, SIGNAL(authenticated()), this, SLOT(authenticated())); connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this, SLOT(authenticationFailed(const QByteArray &))); @@ -40,104 +79,151 @@ SmtpClient::~SmtpClient() { if (smtp) { delete smtp; - smtp = 0; + smtp = nullptr; } } +int SmtpClient::maxRetries() const +{ + // Bounded like retryDelaySeconds(): an unset/blank value must not turn the + // first transient failure into a permanent one (attempts >= 0 is immediate). + return qBound(1, settingsCache->value("smtp/maxretries", DEFAULT_MAX_RETRIES).toInt(), 100); +} + +int SmtpClient::retryDelaySeconds() const +{ + return qBound(1, settingsCache->value("smtp/retrydelay", DEFAULT_RETRY_DELAY).toInt(), + static_cast(MAX_RETRY_DELAY)); +} + +QDateTime SmtpClient::nextRetryTime(int attempts, int retryDelay) const +{ + // Exponential backoff, capped to avoid hammering an unreachable server. + const int shift = qMax(0, qMin(attempts - 1, MAX_BACKOFF_SHIFT)); + const qint64 delay = qMin(retryDelay * qint64(1) << shift, MAX_RETRY_DELAY); + return QDateTime::currentDateTime().addSecs(delay); +} + +SmtpClient::PendingEmail SmtpClient::buildEmail(const QString &nickname, + EmailType type, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &email, + qint64 rowId) +{ + QString name = settingsCache->value("smtp/name", "").toString(); + + PendingEmail pending; + pending.userName = nickname; + pending.type = type; + pending.token = token; + pending.rowId = rowId; + pending.maxAttempts = maxRetries(); + pending.retryDelay = retryDelaySeconds(); + pending.nextAttempt = QDateTime::currentDateTime(); + + QString emailBody = body; + emailBody.replace("%username", nickname).replace("%token", token); + + pending.message.setSender(name + " <" + email + ">"); + pending.message.addRecipient(recipient); + pending.message.setSubject(subject); + pending.message.setBody(emailBody); + + return pending; +} + +bool SmtpClient::enqueue(const PendingEmail &email) +{ + const QString key = pendingKey(email.userName, email.type, email.token); + if (pendingEmails.contains(key)) { + qCDebug(SmtpClientLog) << "Email already pending for" << email.userName << "- not duplicating"; + return false; + } + + // A fresher database row for the same user/type supersedes a stale pending + // entry: drop the stale one so only the current token is ever delivered. + // (Any in-flight mapping for the dropped key is removed as well; the + // mailSent/recordFailed lookups tolerate missing entries.) + for (auto it = pendingEmails.begin(); it != pendingEmails.end();) { + if (it.value().userName == email.userName && it.value().type == email.type) { + for (auto mapIt = mailIdToUser.begin(); mapIt != mailIdToUser.end();) { + if (mapIt.value() == it.key()) { + mapIt = mailIdToUser.erase(mapIt); + } else { + ++mapIt; + } + } + qCDebug(SmtpClientLog) << "Replacing stale pending email for" << email.userName; + it = pendingEmails.erase(it); + } else { + ++it; + } + } + + pendingEmails.insert(key, email); + qCDebug(SmtpClientLog) << "Enqueued" << (email.type == EmailType::Activation ? "activation" : "password reset") + << "mail to" << email.userName; + return true; +} + bool SmtpClient::enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token) { - QString email = settingsCache->value("smtp/email", "").toString(); - QString name = settingsCache->value("smtp/name", "").toString(); + if (pendingEmails.contains(pendingKey(nickname, EmailType::Activation, token))) { + qCDebug(SmtpClientLog) << "Activation email already pending for" << nickname << "- skipping"; + return false; + } + QString subject = settingsCache->value("smtp/subject", "").toString(); QString body = settingsCache->value("smtp/body", "").toString(); + QString email = settingsCache->value("smtp/email", "").toString(); - if (email.isEmpty()) { - qDebug() << "[MAIL] Missing sender email in configuration"; + if (!validateEmailFields(email, subject, body, recipient, token, nickname)) { return false; } - if (subject.isEmpty()) { - qDebug() << "[MAIL] Missing subject field in configuration"; - return false; - } - - if (body.isEmpty()) { - qDebug() << "[MAIL] Missing body field in configuration"; - return false; - } - - if (recipient.isEmpty()) { - qDebug() << "[MAIL] Missing recipient field for user " << nickname; - return false; - } - - if (token.isEmpty()) { - qDebug() << "[MAIL] Missing token field for user " << nickname; - return false; - } - - QxtMailMessage message; - message.setSender(name + " <" + email + ">"); - message.addRecipient(recipient); - message.setSubject(subject); - message.setBody(body.replace("%username", nickname).replace("%token", token)); - - int id = smtp->send(message); - qDebug() << "[MAIL] Enqueued mail to" << recipient << "as" << id; - return true; + return enqueue(buildEmail(nickname, EmailType::Activation, subject, body, recipient, token, email)); } -bool SmtpClient::enqueueForgotPasswordTokenMail(const QString &nickname, const QString &recipient, const QString &token) +bool SmtpClient::enqueueForgotPasswordTokenMail(const QString &nickname, + const QString &recipient, + const QString &token, + qint64 rowId) { - QString email = settingsCache->value("smtp/email", "").toString(); - QString name = settingsCache->value("smtp/name", "").toString(); + if (pendingEmails.contains(pendingKey(nickname, EmailType::ForgotPassword, token))) { + qCDebug(SmtpClientLog) << "Password reset email already pending for" << nickname << "- skipping"; + return false; + } + QString subject = settingsCache->value("forgotpassword/subject", "").toString(); QString body = settingsCache->value("forgotpassword/body", "").toString(); + QString email = settingsCache->value("smtp/email", "").toString(); - if (email.isEmpty()) { - qDebug() << "[MAIL] Missing sender email in configuration"; + if (!validateEmailFields(email, subject, body, recipient, token, nickname)) { return false; } - if (subject.isEmpty()) { - qDebug() << "[MAIL] Missing subject field in configuration"; - return false; - } - - if (body.isEmpty()) { - qDebug() << "[MAIL] Missing body field in configuration"; - return false; - } - - if (recipient.isEmpty()) { - qDebug() << "[MAIL] Missing recipient field for user " << nickname; - return false; - } - - if (token.isEmpty()) { - qDebug() << "[MAIL] Missing token field for user " << nickname; - return false; - } - - QxtMailMessage message; - message.setSender(name + " <" + email + ">"); - message.addRecipient(recipient); - message.setSubject(subject); - message.setBody(body.replace("%username", nickname).replace("%token", token)); - - int id = smtp->send(message); - qDebug() << "[MAIL] Enqueued mail to" << recipient << "as" << id; - return true; + return enqueue(buildEmail(nickname, EmailType::ForgotPassword, subject, body, recipient, token, email, rowId)); } void SmtpClient::sendAllEmails() { // still connected from the previous round - if (smtp->socket()->state() == QAbstractSocket::ConnectedState) { + if (smtp->socket()->state() == QAbstractSocket::ConnectedState || + smtp->socket()->state() == QAbstractSocket::ConnectingState || + smtp->socket()->state() == QAbstractSocket::HostLookupState || + smtp->socket()->state() == QAbstractSocket::ClosingState) { return; } - if (smtp->pendingMessages() == 0) { + if (pendingEmails.isEmpty()) { + return; + } + + // Respect connection-level backoff after auth/conn/encryption failures. + if (connectionAttempts > 0 && QDateTime::currentDateTime() < connectionBackoffUntil) { return; } @@ -151,6 +237,16 @@ void SmtpClient::sendAllEmails() smtp->setUsername(username); smtp->setPassword(password); + // Push due emails into QxtSmtp *before* connecting so they are already + // queued when the authenticated() signal fires and QxtSmtp's internal + // sendNext() runs. + relayoutForSending(); + + // Nothing due right now — skip the SMTP round-trip. + if (mailIdToUser.isEmpty()) { + return; + } + // Connect if (connectionType == "ssl") { if (acceptAllCerts) { @@ -162,64 +258,286 @@ void SmtpClient::sendAllEmails() } } -void SmtpClient::authenticated() +void SmtpClient::relayoutForSending() { - qDebug() << "[MAIL] authenticated"; -} + // Rebuild the SMTP buffer exclusively from our authoritative set, so a reconnect + // after a partial transmission never results in duplicate deliveries. + // reset() also clears the internal state machine so that send() does not + // fire sendNext() prematurely on a not-yet-connected socket. + smtp->reset(); + mailIdToUser.clear(); -void SmtpClient::authenticationFailed(const QByteArray &msg) -{ - qDebug() << "[MAIL] authenticationFailed" << QString(msg); -} - -void SmtpClient::connected() -{ - qDebug() << "[MAIL] connected"; -} - -void SmtpClient::connectionFailed(const QByteArray &msg) -{ - qDebug() << "[MAIL] connectionFailed" << QString(msg); -} - -void SmtpClient::disconnected() -{ - qDebug() << "[MAIL] disconnected"; -} - -void SmtpClient::encrypted() -{ - qDebug() << "[MAIL] encrypted"; -} - -void SmtpClient::encryptionFailed(const QByteArray &msg) -{ - qDebug() << "[MAIL] encryptionFailed" << QString(msg); - qDebug() << "[MAIL] Try enabling the \"acceptallcerts\" option in servatrice.ini"; + const QDateTime now = QDateTime::currentDateTime(); + for (auto it = pendingEmails.begin(); it != pendingEmails.end(); ++it) { + PendingEmail &pending = it.value(); + if (pending.nextAttempt > now) { + continue; // not yet due; honours the backoff schedule + } + int id = smtp->send(pending.message); + mailIdToUser.insert(id, pendingKey(pending.userName, pending.type, pending.token)); + } } void SmtpClient::finished() { - qDebug() << "[MAIL] finished"; + qCDebug(SmtpClientLog) << "finished"; smtp->disconnectFromHost(); } +void SmtpClient::authenticated() +{ + qCDebug(SmtpClientLog) << "authenticated"; + // The session has actually succeeded (TCP connect alone proves nothing: + // TLS/auth may still fail), so the connection-level backoff resets here. + connectionAttempts = 0; + // Start the idle timer here, once the connection is fully ready to send, + // rather than on TCP connect: SSL/STARTTLS handshake and auth may take a + // while and must not be cut short by the idle timeout. + idleTimer->start(IDLE_TIMEOUT_MS); +} + +void SmtpClient::authenticationFailed(const QByteArray &msg) +{ + qCWarning(SmtpClientLog) << "authenticationFailed" << QString::fromUtf8(msg); + idleTimer->stop(); + recordConnectionFailure(); + mailIdToUser.clear(); + smtp->disconnectFromHost(); +} + +void SmtpClient::connected() +{ + qCDebug(SmtpClientLog) << "connected"; + // NOTE: connectionAttempts is deliberately not reset here. This fires on + // TCP connect, before the TLS handshake and AUTH; resetting here would pin + // the connection-level backoff at its base delay for exactly the failures + // (bad password/cert/STARTTLS) it is meant to cover. It resets in + // authenticated() and on the first successful mailSent instead. +} + +void SmtpClient::connectionFailed(const QByteArray &msg) +{ + qCWarning(SmtpClientLog) << "connectionFailed" << QString::fromUtf8(msg); + idleTimer->stop(); + recordConnectionFailure(); + mailIdToUser.clear(); + smtp->disconnectFromHost(); +} + +void SmtpClient::disconnected() +{ + qCDebug(SmtpClientLog) << "disconnected - emails still pending:" << pendingEmails.count(); + idleTimer->stop(); + // A mid-session drop (peer close/RST past the connect phase, or the idle + // timeout firing mid-batch) emits no mailFailed/connectionFailed signal, so + // route every in-flight mail through recordFailed(): otherwise attempts and + // backoff are bypassed and the mails requeue with stale state forever. + const QList inFlight = mailIdToUser.keys(); + for (int mailID : inFlight) { + recordFailed(mailID); + } + mailIdToUser.clear(); +} + +void SmtpClient::encrypted() +{ + qCDebug(SmtpClientLog) << "encrypted"; +} + +void SmtpClient::encryptionFailed(const QByteArray &msg) +{ + qCWarning(SmtpClientLog) << "encryptionFailed" << QString::fromUtf8(msg); + qCWarning(SmtpClientLog) << "Try enabling the \"acceptallcerts\" option in servatrice.ini"; + idleTimer->stop(); + recordConnectionFailure(); + mailIdToUser.clear(); + smtp->disconnectFromHost(); +} + +void SmtpClient::idleTimeout() +{ + qCDebug(SmtpClientLog) << "idle timeout - disconnecting"; + smtp->disconnectFromHost(); +} + +void SmtpClient::recordFailed(int mailID) +{ + auto it = mailIdToUser.find(mailID); + if (it == mailIdToUser.end()) { + return; + } + const QString key = it.value(); + mailIdToUser.erase(it); + + auto pendingIt = pendingEmails.find(key); + if (pendingIt == pendingEmails.end()) { + return; + } + + PendingEmail &pending = pendingIt.value(); + pending.attempts++; + + if (pending.attempts >= pending.maxAttempts) { + const QString userName = pending.userName; + const EmailType type = pending.type; + const QString token = pending.token; + const qint64 rowId = pending.rowId; + qCWarning(SmtpClientLog) << "Email to" << userName << "permanently failed after" << pending.attempts + << "attempts - dropping from pending set"; + pendingEmails.erase(pendingIt); + emit mailPermanentlyFailed(userName, type, FailureReason::RetryExhausted, token, rowId); + return; + } + + pending.nextAttempt = nextRetryTime(pending.attempts, pending.retryDelay); + qCWarning(SmtpClientLog) << "Email to" << pending.userName << "failed (attempt" << pending.attempts << "of" + << pending.maxAttempts << "), retrying" << pending.nextAttempt.toString(Qt::ISODate); +} + +void SmtpClient::recordConnectionFailure() +{ + ++connectionAttempts; + connectionBackoffUntil = nextRetryTime(connectionAttempts, retryDelaySeconds()); + qCWarning(SmtpClientLog) << "Connection-layer failure #" << connectionAttempts << "- next attempt after" + << connectionBackoffUntil.toString(Qt::ISODate); + + // Connection failures count toward the per-email retry budget so that + // undeliverable mail eventually gives up rather than retrying forever. + QList keysToRemove; + for (auto it = pendingEmails.begin(); it != pendingEmails.end(); ++it) { + PendingEmail &pending = it.value(); + pending.attempts++; + if (pending.attempts >= pending.maxAttempts) { + qCWarning(SmtpClientLog) << "Email to" << pending.userName << "permanently failed after" << pending.attempts + << "connection failures - dropping from pending set"; + keysToRemove.append(it.key()); + emit mailPermanentlyFailed(pending.userName, pending.type, FailureReason::RetryExhausted, pending.token, + pending.rowId); + } + } + for (const QString &key : keysToRemove) { + pendingEmails.remove(key); + } +} + +bool SmtpClient::validateEmailFields(const QString &email, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &nickname) +{ + if (email.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing sender email in configuration"; + return false; + } + if (subject.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing subject field in configuration"; + return false; + } + if (body.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing body field in configuration"; + return false; + } + if (recipient.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing recipient field for user" << nickname; + return false; + } + if (token.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing token field for user" << nickname; + return false; + } + return true; +} + void SmtpClient::mailFailed(int mailID, int errorCode, const QByteArray &msg) { - qDebug() << "[MAIL] mailFailed id=" << mailID << " errorCode=" << errorCode << "msg=" << QString(msg); + qCWarning(SmtpClientLog) << "mailFailed id=" << mailID << " errorCode=" << errorCode + << "msg=" << QString::fromUtf8(msg); + // Progress was made (the server answered), so the idle timer measures from + // here rather than acting as a cap on the whole session. + idleTimer->start(IDLE_TIMEOUT_MS); + recordFailed(mailID); } void SmtpClient::mailSent(int mailID) { - qDebug() << "[MAIL] mailSent" << mailID; + qCDebug(SmtpClientLog) << "mailSent" << mailID; + // A delivered mail proves the session works: reset the connection-level + // backoff and restart the idle timer from this point of progress. + connectionAttempts = 0; + idleTimer->start(IDLE_TIMEOUT_MS); + + auto it = mailIdToUser.find(mailID); + if (it == mailIdToUser.end()) { + return; + } + const QString key = it.value(); + mailIdToUser.erase(it); + + auto pendingIt = pendingEmails.find(key); + if (pendingIt == pendingEmails.end()) { + return; + } + + const QString userName = pendingIt.value().userName; + const EmailType type = pendingIt.value().type; + const QString token = pendingIt.value().token; + const qint64 rowId = pendingIt.value().rowId; + pendingEmails.erase(pendingIt); + qCDebug(SmtpClientLog) << "Delivered email to" << userName; + emit mailDelivered(userName, type, token, rowId); } void SmtpClient::recipientRejected(int mailID, const QString &address, const QByteArray &msg) { - qDebug() << "[MAIL] recipientRejected id=" << mailID << " address=" << address << "msg=" << QString(msg); + qCWarning(SmtpClientLog) << "recipientRejected id=" << mailID << " address=" << address + << "msg=" << QString::fromUtf8(msg); + if (!isPermanentSmtpReply(msg)) { + // Transient 4xx (e.g. greylisting): consume one attempt and reschedule + // with backoff instead of destroying the durable queue row. + recordFailed(mailID); + return; + } + // A rejected recipient is a permanent condition; consider the mail undeliverable. + auto it = mailIdToUser.find(mailID); + if (it != mailIdToUser.end()) { + const QString key = it.value(); + if (auto pendingIt = pendingEmails.find(key); pendingIt != pendingEmails.end()) { + const QString userName = pendingIt.value().userName; + const EmailType type = pendingIt.value().type; + const QString token = pendingIt.value().token; + const qint64 rowId = pendingIt.value().rowId; + pendingEmails.erase(pendingIt); + qCWarning(SmtpClientLog) << "Email to" << userName << "rejected - dropping from pending set"; + emit mailPermanentlyFailed(userName, type, FailureReason::RecipientRejected, token, rowId); + } + mailIdToUser.erase(it); + } } void SmtpClient::senderRejected(int mailID, const QString &address, const QByteArray &msg) { - qDebug() << "[MAIL] senderRejected id=" << mailID << " address=" << address << "msg=" << QString(msg); + qCWarning(SmtpClientLog) << "senderRejected id=" << mailID << " sender=" << address + << "msg=" << QString::fromUtf8(msg); + if (!isPermanentSmtpReply(msg)) { + // Transient 4xx on MAIL FROM (e.g. rate limiting): retry with backoff. + recordFailed(mailID); + return; + } + // A rejected sender is a permanent condition; consider the mail undeliverable. + auto it = mailIdToUser.find(mailID); + if (it != mailIdToUser.end()) { + const QString key = it.value(); + if (auto pendingIt = pendingEmails.find(key); pendingIt != pendingEmails.end()) { + const QString userName = pendingIt.value().userName; + const EmailType type = pendingIt.value().type; + const QString token = pendingIt.value().token; + const qint64 rowId = pendingIt.value().rowId; + pendingEmails.erase(pendingIt); + qCWarning(SmtpClientLog) << "Email to" << userName << "sender rejected - dropping from pending set"; + emit mailPermanentlyFailed(userName, type, FailureReason::SenderRejected, token, rowId); + } + mailIdToUser.erase(it); + } } diff --git a/servatrice/src/smtpclient.h b/servatrice/src/smtpclient.h index 8fc4e35cb..9fc11bf36 100644 --- a/servatrice/src/smtpclient.h +++ b/servatrice/src/smtpclient.h @@ -1,26 +1,67 @@ #ifndef SMTPCLIENT_H #define SMTPCLIENT_H +#include "smtp/qxtmailmessage.h" + +#include +#include #include #include +#include #include class QxtSmtp; class QByteArray; +/** + * @brief Types of e-mail the SMTP client is able to deliver. + */ +enum class EmailType +{ + Activation, + ForgotPassword +}; + +Q_DECLARE_METATYPE(EmailType) + +/** + * @brief Why a permanent e-mail delivery failure occurred. + */ +enum class FailureReason +{ + RetryExhausted, + RecipientRejected, + SenderRejected +}; + +Q_DECLARE_METATYPE(FailureReason) + class SmtpClient : public QObject { Q_OBJECT public: - SmtpClient(QObject *parent = 0); - ~SmtpClient(); + SmtpClient(QObject *parent = nullptr); + ~SmtpClient() override; + +public slots: + bool enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token); + bool enqueueForgotPasswordTokenMail(const QString &nickname, + const QString &recipient, + const QString &token, + qint64 rowId = -1); + void sendAllEmails(); + +signals: + void mailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId); + void mailPermanentlyFailed(const QString &userName, + EmailType type, + FailureReason reason, + const QString &token, + qint64 rowId); protected: QxtSmtp *smtp; -public slots: - bool enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token); - bool enqueueForgotPasswordTokenMail(const QString &nickname, const QString &recipient, const QString &token); - void sendAllEmails(); + protected slots: void authenticated(); void authenticationFailed(const QByteArray &msg); @@ -30,10 +71,55 @@ protected slots: void encrypted(); void encryptionFailed(const QByteArray &msg); void finished(); + void idleTimeout(); void mailFailed(int mailID, int errorCode, const QByteArray &msg); void mailSent(int mailID); void recipientRejected(int mailID, const QString &address, const QByteArray &msg); void senderRejected(int mailID, const QString &address, const QByteArray &msg); + +private: + struct PendingEmail + { + QString userName; + EmailType type; + QString token; + // Database row id for tables that allow multiple rows per user + // ({prefix}_forgot_password); -1 when the name alone identifies the row. + qint64 rowId = -1; + QxtMailMessage message; + int attempts = 0; + int maxAttempts = 0; + int retryDelay = 0; + QDateTime nextAttempt; + }; + + PendingEmail buildEmail(const QString &nickname, + EmailType type, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &email, + qint64 rowId = -1); + bool enqueue(const PendingEmail &email); + void relayoutForSending(); + QDateTime nextRetryTime(int attempts, int retryDelay) const; + void recordFailed(int mailID); + void recordConnectionFailure(); + bool validateEmailFields(const QString &email, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &nickname); + int maxRetries() const; + int retryDelaySeconds() const; + + QHash pendingEmails; + QHash mailIdToUser; + QTimer *idleTimer; + int connectionAttempts = 0; + QDateTime connectionBackoffUntil; }; -#endif \ No newline at end of file +#endif From 96b819d7c8fddd89695476b175ac3b2dd022b7b7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:19:10 +0200 Subject: [PATCH 020/247] Support tab-separated deck imports (#4222) (#7267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party deck tools (e.g. Delver Lens) export decks as tab-separated rows such as "2 Card Name Set Name". Card names never contain tab characters, so a leading numeric column marks a tabular record: the first two columns are read as the quantity and card name, and any further columns are discarded as printer/set metadata. - deck_list: parse tab-delimited rows before whitespace collapsing - tests: cover tab-separated mainboard and sideboard imports - docs: document the accepted plain-text import format (fills the TODO) Co-authored-by: Lukas Brübach --- .../deck_management/importing_decks.md | 13 +++++- .../deck_list/deck_list_plain_text_parser.cpp | 40 ++++++++++++++++--- .../loading_from_clipboard_test.cpp | 14 +++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md index e9626d57f..4aab7ad2c 100644 --- a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md +++ b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md @@ -38,7 +38,18 @@ Selecting this action will open a new text editor dialog with the contents of yo The import dialog expects each line to be a card with the following format: -TODO +`[quantity] Card Name [options]` + +- `quantity` is the number of copies and can be written as `2`, `2x` or `2X`. If omitted, a single copy is imported. +- `options` are appended after the card name and are understood on a best-effort basis: + - a set code and collector number, either as `Card Name (SET) 123` or `Card Name (SET) 123-1` + - a foil marker, `Card Name *F*` +- Lines can also carry a `SB:` prefix or a set code prefix (`[SET]`) — Cockatrice tries to stay compatible with the most + common deck formats. + +Tab-separated exports from third-party apps (such as Delver Lens) are also accepted: any line whose first column is a +plain quantity treats the following columns as the card name and printer/set metadata (which is discarded), for example +`2\tCard Name\tSet Name`. Each card should be on a separate line and there should be no empty lines between cards. The first empty line between two blocks of cards will be considered as the divider between mainboard and sideboard. diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp index 61af36540..fb6a61769 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp @@ -33,6 +33,7 @@ bool parse(QTextStream &in, // Regex for advanced card parsing static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); + static const QRegularExpression reTabQuantity(R"(^\d+[xX]?$)"); // Regex for extracting set code and collector number with attached symbols static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); @@ -110,8 +111,34 @@ bool parse(QTextStream &in, continue; } - QString cardName = match.captured().simplified(); + const QString rawLine = match.captured(); + QString cardName = rawLine.simplified(); bool sideboard = false; + int amount = 1; + + // Card names never contain tab characters, so a leading numeric field + // marks a tabular record emitted by third-party deck tools (e.g. Delver + // Lens "2\tCard Name\tSet Name"). The first two columns are the quantity + // and the card name; any further columns are printer/set metadata and + // are discarded. + bool hasTabQuantity = false; + const auto tabFields = rawLine.split('\t'); + int firstField = 0; + while (firstField < tabFields.size() && tabFields.at(firstField).trimmed().isEmpty()) { + ++firstField; + } + if (firstField + 1 < tabFields.size()) { + const QString quantity = tabFields.at(firstField).trimmed(); + if (reTabQuantity.match(quantity).hasMatch()) { + hasTabQuantity = true; + QString digits = quantity; + if (digits.endsWith('x') || digits.endsWith('X')) { + digits.chop(1); + } + amount = digits.toInt(); + cardName = tabFields.at(firstField + 1).simplified(); + } + } // Sideboard detection if (sBStart < 0) { @@ -156,11 +183,12 @@ bool parse(QTextStream &in, } // check if a specific amount is mentioned - int amount = 1; - match = reMultiplier.match(cardName); - if (match.hasMatch()) { - amount = match.captured(1).toInt(); - cardName = match.captured(2); + if (!hasTabQuantity) { + match = reMultiplier.match(cardName); + if (match.hasMatch()) { + amount = match.captured(1).toInt(); + cardName = match.captured(2); + } } // Normalize the card name diff --git a/tests/loading_from_clipboard/loading_from_clipboard_test.cpp b/tests/loading_from_clipboard/loading_from_clipboard_test.cpp index 840d926ff..e1797818e 100644 --- a/tests/loading_from_clipboard/loading_from_clipboard_test.cpp +++ b/tests/loading_from_clipboard/loading_from_clipboard_test.cpp @@ -80,6 +80,20 @@ TEST(LoadingFromClipboardTest, WeirdWhitespaceIsIgnored) testDeck(clipboard, result); } +TEST(LoadingFromClipboardTest, TabSeparatedImport) +{ + QString clipboard("2\tMystic Snake\tStreets of New Capenna\n" + "3x\tCounterspell\tNPH\n" + "\t2\tSol Ring\tFMB\n" + "1\tForest\n" + "\n" + "2x\tDoom Blade\tM11\n"); + + Result result("", "", {{"Mystic Snake", 2}, {"Counterspell", 3}, {"Sol Ring", 2}, {"Forest", 1}}, + {{"Doom Blade", 2}}); + testDeck(clipboard, result); +} + TEST(LoadingFromClipboardTest, RemoveBlankEntriesFromBeginningAndEnd) { QString clipboard("\n" From bac2d285e31d554d075c1f13f515149777f3f795 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:14:29 +0200 Subject: [PATCH 021/247] [Versioning] Bump version number for 3.1.2 beta release. (#7501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b272d4ad8..bee47972c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.1.1) +project("Cockatrice" VERSION 3.1.2) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) From a712553c812dd0a7fe761aabec5e9287642d0dcf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:29:16 +0200 Subject: [PATCH 022/247] [CardDB] Reload after the first run enables all sets without freezing startup (#7470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Reload when the first run enables all sets checkUnknownSets() enabled every set when none was enabled, but the database had already been parsed under that empty enablement, so the parsers had stripped every printing. It only notified the models and never reloaded, leaving the deck editor's card database display empty until the next launch. Route the all-sets-enabled case through the same reload the new-sets path uses, and skip caching a snapshot whose sets are all disabled. * [CardDB] Own checkUnknownSets after startup and fix the cache skip The loader's initialLoadComplete guard skipped checkUnknownSets() on the first successful load of the session, so a download that landed after startupConfigCheck() and the onboarding wizard had already run (e.g. skipping the wizard download and fetching later from the menu) left every set disabled and the deck editor blank until the next launch. The guard also let the check run on the worker before the queued snapshot swap, and alongside reloadCardDatabasesAndNotify()'s own check, double-emitting cardDatabaseNewSetsFound on manual reloads. Move the check to the callers that load after startup: the post-update, custom-import, and spoiler paths run it via a QFutureWatcher once the finished snapshot has been swapped in, and the loader's synchronous path-change reload runs it directly. Judge the cache skip by the parsed sets' own enablement, what the parsers actually saw, instead of getEnabledSetNames(), which reports empty for the noop controller even though it enables every set. Wait for the first-run reparse before opening Manage Sets so the dialog cannot bind to the pre-reload set instances. * [CardDB] Queue post-reload check and unblock first-run Manage Sets reloadCardDatabasesAndNotify() no longer emits cardDatabaseEnabledSetsChanged after the swap's Reset already rebuilt the models (second 36k rebuild froze startup), and its follow-up checkUnknownSets() is queued to the GUI thread so it sees swapped-in data. cardDatabaseAllNewSetsEnabled() chains Welcome/Manage Sets off a QFutureWatcher instead of waitForFinished() blocking the event loop during the reparse. * [CardDB] Batch the first-run enable-all into a single settings flush checkUnknownSets() enabled every set with ~3 QSettings rewrite cycles per set (sort key, known, enabled), and each setValue re-opens and re-syncs the whole cardDatabase.ini. With a full database (874 sets) that froze the GUI thread for ~9s after the database download (LagMonitor: event loop stalled for 9129 ms, measured offscreen with a real 59MB cards.xml), reading as a total application hang that recovers on its own. Persist the enable-all through the controller's batched saveSets() in a single flush instead, mirroring what SetsModel::save() already does: CardSetList::enableAllBatched() assigns fresh sort keys and marks every set known+enabled in memory, then writes all records at once. SetSaveData gains the isKnown flag so the batch covers all three options, and SetsModel::save() passes the sets' known state through (unchanged behavior there). Also drop the cardDatabaseEnabledSetsChanged emission from the all-sets-enabled branch: the reload chained off cardDatabaseAllNewSetsEnabled rebuilds the models from the re-parsed snapshot via cardDatabaseReset, so emitting both rebuilt the 36k-card models twice on the GUI thread. Adds a cache-invalidation test driving the real checkUnknownSets() first-run path. * [CardDB] Batch the new-sets-found enable-all the same way The first-run batching fixed the 874-set case, but the new-sets-found dialog path (enableAllUnknownSets/markAllSetsAsKnown) still wrote per set and per option, each setValue re-opening and re-syncing the whole cardDatabase.ini. Users with hundreds of custom sets hit the same multi-second GUI stall when answering the dialog. Persist both through the controller's batched saveSets() in a single flush, writing only changed sets (so the common no-op check skips the flush entirely). Removes the now-unused write-through CardSetList methods (enableAll, enableAllUnknown, markAllAsKnown, guessSortKeys) so the batched flush is the only way to persist bulk set changes. Extends the test controller mock with per-write/batched-flush counters and adds tests asserting both dialog paths persist with exactly one flush and zero individual writes. --------- Co-authored-by: Lukas Brübach --- .../spoiler_background_updater.cpp | 11 +- cockatrice/src/interface/window_main.cpp | 61 ++++++-- cockatrice/src/interface/window_main.h | 8 ++ cockatrice/src/main.cpp | 11 +- .../card/database/card_database.cpp | 31 ++-- .../card/database/card_database_loader.cpp | 43 ++++-- .../card/database/card_database_loader.h | 1 - .../libcockatrice/card/set/card_set.h | 4 + .../libcockatrice/card/set/card_set_list.cpp | 125 +++++++++------- .../libcockatrice/card/set/card_set_list.h | 33 ++--- .../interface_card_set_priority_controller.h | 9 ++ .../database/card_set/card_sets_model.cpp | 2 +- .../settings/card_database_settings.cpp | 4 +- tests/carddatabase/carddatabase_test.cpp | 133 ++++++++++++++++++ 14 files changed, 364 insertions(+), 112 deletions(-) diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index ae5e92d6c..192029b23 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -177,7 +178,15 @@ bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data) // Data written, so reload the card database qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written"; - const auto reloadOk = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); + // Spoilers can introduce sets the config has never seen; the loader never + // runs checkUnknownSets() itself, so run it once the finished snapshot has + // been swapped in. + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [watcher] { + watcher->deleteLater(); + CardDatabaseManager::getInstance()->checkUnknownSets(); + }); + watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); })); // If the user has notifications enabled, let them know // when the database was last updated diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 4f363181e..815cec1d2 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -70,6 +70,7 @@ #include #include #include +#include #include #include #include @@ -640,10 +641,10 @@ void MainWindow::startupConfigCheck() const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; - // checkUnknownSets() is intentionally deferred from the card database load - // (which runs in main() before MainWindow exists) so that - // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live - // receivers when emitted. + // The loader never runs checkUnknownSets() itself (see main.cpp): the + // front-loaded parse runs before MainWindow exists, so + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no + // receivers. MainWindow owns the check after startup. // On a clean install the onboarding wizard owns the first-run experience; // wait until it closes so the legacy "all sets enabled" welcome and the // Manage Sets dialog don't appear first. @@ -1137,18 +1138,35 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow void MainWindow::cardDatabaseAllNewSetsEnabled() { - if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) { - // The onboarding wizard owns the first-run messaging on a clean install, - // and with no card data there are no sets to have enabled. + auto *cardDatabase = CardDatabaseManager::getInstance(); + + // With no card data there is nothing to have enabled and nothing to reparse. + if (cardDatabase->getCardList().isEmpty()) { return; } - QMessageBox::information( - this, tr("Welcome"), - tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the card " - "database have been enabled.\nRead more about changing the set order or disabling specific sets and " - "consequent effects in the \"Manage Sets\" dialog.")); - actManageSets(); + // checkUnknownSets() enabled every set after the database had already been + // parsed under the (empty) first-run enablement, so the parser dropped every + // printing of every set. Reparse now that the sets are enabled, otherwise the + // deck editor's card database display stays empty until the next launch. + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher] { + watcher->deleteLater(); + if (firstRunWizardActive) { + // The onboarding wizard owns the first-run messaging on a clean install. + return; + } + QMessageBox::information( + this, tr("Welcome"), + tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the " + "card " + "database have been enabled.\nRead more about changing the set order or disabling specific sets and " + "consequent effects in the \"Manage Sets\" dialog.")); + // The reload above replaces the set instances, so Manage Sets must open + // only after the finished snapshot has been swapped in. + actManageSets(); + }); + watcher->setFuture(QtConcurrent::run([cardDatabase] { cardDatabase->reloadCardDatabasesAndNotify(); })); } /* CARD UPDATER */ @@ -1321,7 +1339,19 @@ void MainWindow::exitCardDatabaseUpdate() cardUpdateProcess = nullptr; statusBar()->clearMessage(); - const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); + // This can be the first successful load of the session (e.g. the onboarding + // download), so it must own the set check for that load. + reloadCardDatabaseAndCheckSets(); +} + +void MainWindow::reloadCardDatabaseAndCheckSets() +{ + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [watcher] { + watcher->deleteLater(); + CardDatabaseManager::getInstance()->checkUnknownSets(); + }); + watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); })); } void MainWindow::cardUpdateError(QProcess::ProcessError err) @@ -1523,7 +1553,8 @@ void MainWindow::actAddCustomSet() QMessageBox::information( this, tr("Load sets/cards"), tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database.")); - const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); + // Imported files can introduce sets the config has never seen. + reloadCardDatabaseAndCheckSets(); } else { QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import.")); } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 6b1186d3c..ee8873000 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -158,6 +158,14 @@ private: } void createCardUpdateProcess(bool background = false); void exitCardDatabaseUpdate(); + /** + * @brief Reloads the card database on a worker thread and runs checkUnknownSets() once the finished + * snapshot has been swapped in. + * + * The loader never runs checkUnknownSets() itself (see main.cpp); post-update loads that can introduce + * new sets must own the check, and it has to run after the queued snapshot swap, not on the worker. + */ + void reloadCardDatabaseAndCheckSets(); /** * @brief Tries to bring the open card database updater window to the front. * @return true when the updater acknowledged the request diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index b9cc8dabc..3f63efc3b 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -343,11 +343,12 @@ int main(int argc, char *argv[]) // contention that happens when the load runs alongside window construction. // The CardDatabaseModel populates from the already-loaded data in its // constructor, so the window appears fully populated with no startup lag. - // Note: checkUnknownSets() is deferred from the initial load to - // MainWindow::startupConfigCheck() so that - // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live - // receivers when emitted. Subsequent reloads (e.g. path changes) call - // checkUnknownSets() directly from the loader after the first load. + // Note: checkUnknownSets() is deliberately not run for this front-loaded + // parse. It would run before MainWindow exists, so + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no + // receivers, and enabling the sets up-front would suppress the first-run + // experience MainWindow::startupConfigCheck() and the onboarding wizard + // provide. Callers that load after startup own the check. CardDatabaseManager::getInstance()->loadCardDatabases(); MainWindow ui; diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 469c8d02c..7b0d5b2f9 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -80,15 +81,17 @@ void CardDatabase::reloadCardDatabasesAndNotify() { // An explicit reload bypasses the binary cache so the files on disk are // always re-read, even when their size/mtime hash still matches the cache. + // Models refresh from the swap's cardDatabaseReset in a single batch, so + // no cardDatabaseEnabledSetsChanged is emitted here: that rebuilt the + // 36k-card models a second time on the GUI thread, which froze startup + // after enabling new/custom sets. The follow-up unknown-sets check is + // queued to the GUI thread so it sees the swapped-in data (this may run + // on a worker via QtConcurrent::run); it is silent unless new sets + // appeared mid-reload. loadStatus = loader->loadCardDatabases(/* ignoreCache */ true); if (loadStatus == Ok) { - checkUnknownSets(); - // A reload reconstructs the exact same enabled-set state, so the cached - // set-name / alt-name data computed during construction is still valid. - // Skipping the per-card refresh avoids a full 36k-card recompute that - // would otherwise duplicate the work already done while building cards. - notifyEnabledSetsChanged(false); + QMetaObject::invokeMethod(this, &CardDatabase::checkUnknownSets, Qt::AutoConnection); } } @@ -207,15 +210,19 @@ void CardDatabase::checkUnknownSets() if (numUnknownSets > 0) { emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames); } else { - _sets.markAllAsKnown(); + _sets.markAllAsKnownBatched(setPriorityController); } } else { // No set enabled. Probably this is the first time running trice - _sets.guessSortKeys(); + _sets.enableAllBatched(setPriorityController); _sets.sortByKey(); - _sets.enableAll(); - notifyEnabledSetsChanged(); + // No cardDatabaseEnabledSetsChanged here: the reload chained off + // cardDatabaseAllNewSetsEnabled re-parses with the sets enabled and its + // swap's cardDatabaseReset rebuilds the models in one batch. Emitting + // both back-to-back rebuilt the 36k-card models twice on the GUI thread, + // stalling startup. (When the card list is empty the slot returns early + // without reloading, but there is nothing to display either.) emit cardDatabaseAllNewSetsEnabled(); } } @@ -223,13 +230,13 @@ void CardDatabase::checkUnknownSets() void CardDatabase::enableAllUnknownSets() { auto _sets = getSetList(); - _sets.enableAllUnknown(); + _sets.enableAllUnknownBatched(setPriorityController); } void CardDatabase::markAllSetsAsKnown() { auto _sets = getSetList(); - _sets.markAllAsKnown(); + _sets.markAllAsKnownBatched(setPriorityController); } void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets) diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 194083615..00b21f88e 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -44,7 +44,13 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent, // the finished snapshot is swapped into the live database on the GUI thread. // when SettingsCache's path changes, trigger reloads - connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { loadCardDatabases(); }); + connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { + loadCardDatabases(); + // This reload runs synchronously on the emitting (GUI) thread, so the + // finished snapshot is already swapped in when it returns: enable any + // sets the newly pointed-at database introduced. + database->checkUnknownSets(); + }); } CardDatabaseLoader::~CardDatabaseLoader() @@ -136,7 +142,23 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) loadCardDatabase(p, data); } - if (!saveToCache(data, sourceHash)) { + // A snapshot parsed while no set is enabled has had every printing + // stripped by the parsers, and the cache reader re-derives enablement + // from the priority controller, so such an entry can never be reused + // meaningfully. Skip writing it so a first-run (blank config) load + // cannot poison the cache with an empty snapshot. The check mirrors + // what the parsers saw: any set still reporting enabled means the + // snapshot is complete for the current enablement. + bool anySetEnabled = false; + for (const CardSetPtr &set : data.sets) { + if (set && set->getEnabled()) { + anySetEnabled = true; + break; + } + } + if (!data.sets.isEmpty() && !anySetEnabled) { + qCInfo(CardDatabaseLoadingLog) << "Skipping binary cache: no set is enabled yet, snapshot was stripped"; + } else if (!saveToCache(data, sourceHash)) { qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath(); } } @@ -148,18 +170,15 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) database->refreshCachedReverseRelatedCards(data.cards); qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success"; + // NOTE: checkUnknownSets() is intentionally NOT called here. During the + // front-loaded parse in main() MainWindow does not exist yet, so + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would be + // emitted with no receivers, and enabling the sets up-front would + // suppress the first-run experience MainWindow::startupConfigCheck() + // and the onboarding wizard are designed around. Callers that load + // after startup own the check (and any reload it triggers). emit databaseDataReady(std::move(data)); emit loadingFinished(); - // During the front-loaded parse in main() this runs before MainWindow - // exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled - // would be emitted with no receivers. Skip the check on the first load; - // MainWindow::startupConfigCheck() calls checkUnknownSets() once its - // signal connections are live. On subsequent reloads (e.g. path change) - // MainWindow exists and signals have live receivers. - if (initialLoadComplete) { - database->checkUnknownSets(); - } - initialLoadComplete = true; } else { qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed"; emit loadingFailed(); // bring up the settings dialog diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index fffd31d2f..bfbaa0f51 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -179,7 +179,6 @@ private: QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ - bool initialLoadComplete = false; /**< Set after the first successful load. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.h b/libcockatrice_card/libcockatrice/card/set/card_set.h index 538d78852..b9ac74e2d 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set.h @@ -227,6 +227,10 @@ public: * @param _isknown True if known, false if unknown. */ void setIsKnown(bool _isknown); + void setIsKnownInMemory(bool _isknown) + { + isknown = _isknown; + } /** * @brief Determines whether the set has incomplete metadata and should be ignored. diff --git a/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp b/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp index 2e030adab..c4faf1c4c 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp @@ -1,9 +1,11 @@ #include "card_set_list.h" #include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/interface_card_set_priority_controller.h" #include #include +#include #include #include @@ -62,60 +64,87 @@ QStringList CardSetList::getUnknownSetsNames() return sets; } -void CardSetList::enableAllUnknown() +void CardSetList::enableAllUnknownBatched(ICardSetPriorityController *controller) { - for (int i = 0; i < size(); ++i) { - CardSetPtr set = at(i); - if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) { - set->setIsKnown(true); - set->setEnabled(true); - } else if (set && set->getIsKnownIgnored() && !set->getEnabled()) { - set->setEnabled(true); - } - } -} - -void CardSetList::enableAll() -{ - for (int i = 0; i < size(); ++i) { - CardSetPtr set = at(i); - - if (set == nullptr) { - // qCWarning(CardInfoLog) << "enabledAll has null"; - continue; - } - - if (!set->getIsKnownIgnored()) { - set->setIsKnown(true); - } - - set->setEnabled(true); - } -} - -void CardSetList::markAllAsKnown() -{ - for (int i = 0; i < size(); ++i) { - CardSetPtr set = at(i); - if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) { - set->setIsKnown(true); - set->setEnabled(false); - } else if (set && set->getIsKnownIgnored() && !set->getEnabled()) { - set->setEnabled(true); - } - } -} - -void CardSetList::guessSortKeys() -{ - defaultSort(); + QVector saveData; for (int i = 0; i < size(); ++i) { CardSetPtr set = at(i); if (set.isNull()) { - // qCWarning(CardInfoLog) << "guessSortKeys set is null"; continue; } - set->setSortKey(i); + + bool changed = false; + if (!set->getIsKnown() && !set->getIsKnownIgnored()) { + set->setIsKnownInMemory(true); + set->setEnabledInMemory(true); + changed = true; + } else if (set->getIsKnownIgnored() && !set->getEnabled()) { + set->setEnabledInMemory(true); + changed = true; + } + if (changed) { + saveData.append( + {set->getShortName(), static_cast(set->getSortKey()), true, set->getIsKnown()}); + } + } + + if (!saveData.isEmpty()) { + controller->saveSets(saveData); + } +} + +void CardSetList::enableAllBatched(ICardSetPriorityController *controller) +{ + defaultSort(); + + QVector saveData; + saveData.reserve(size()); + for (int i = 0; i < size(); ++i) { + CardSetPtr set = at(i); + if (set.isNull()) { + continue; + } + + const auto sortKey = static_cast(i); + set->setSortKeyInMemory(sortKey); + if (!set->getIsKnownIgnored()) { + set->setIsKnownInMemory(true); + } + set->setEnabledInMemory(true); + saveData.append({set->getShortName(), sortKey, true, set->getIsKnown()}); + } + + if (!saveData.isEmpty()) { + controller->saveSets(saveData); + } +} + +void CardSetList::markAllAsKnownBatched(ICardSetPriorityController *controller) +{ + QVector saveData; + for (int i = 0; i < size(); ++i) { + CardSetPtr set = at(i); + if (set.isNull()) { + continue; + } + + bool changed = false; + if (!set->getIsKnown() && !set->getIsKnownIgnored()) { + set->setIsKnownInMemory(true); + set->setEnabledInMemory(false); + changed = true; + } else if (set->getIsKnownIgnored() && !set->getEnabled()) { + set->setEnabledInMemory(true); + changed = true; + } + if (changed) { + saveData.append({set->getShortName(), static_cast(set->getSortKey()), set->getEnabled(), + set->getIsKnown()}); + } + } + + if (!saveData.isEmpty()) { + controller->saveSets(saveData); } } diff --git a/libcockatrice_card/libcockatrice/card/set/card_set_list.h b/libcockatrice_card/libcockatrice/card/set/card_set_list.h index b9c7b2a7a..f31710b33 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set_list.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set_list.h @@ -40,36 +40,37 @@ public: void sortByKey(); /** - * @brief Reassigns sort keys based on the current order. + * @brief Enables every set with fresh sort keys, persisting in one flush. * - * Calls defaultSort() and then assigns sequential sort keys - * to all sets according to their resulting positions, replacing - * any existing sort keys to ensure consistent ordering. + * Assigns sequential sort keys in default-sorted order and marks every set + * known and enabled, writing all records through the controller's batched + * saveSets() in a single settings flush instead of ~3 QSettings rewrite + * cycles per set, which froze the GUI thread for seconds on a first run + * with a full database. + * @param controller The set priority controller to persist the records with. */ - void guessSortKeys(); + void enableAllBatched(ICardSetPriorityController *controller); /** - * @brief Enables all sets that are unknown or ignored. + * @brief Enables all sets that are unknown or ignored, persisting in one flush. * * Sets that are not marked as known and not ignored are marked as known * and enabled. Ignored-known sets are also enabled, but remain ignored. + * Only changed sets are written, so the common case of a few new sets costs + * a single settings flush no matter how large the database is. + * @param controller The set priority controller to persist the records with. */ - void enableAllUnknown(); + void enableAllUnknownBatched(ICardSetPriorityController *controller); /** - * @brief Enables all sets in the list. - * - * Equivalent to calling `setEnabled(true)` on each entry. - */ - void enableAll(); - - /** - * @brief Marks all sets as known and adjusts their enabled state. + * @brief Marks all sets as known and adjusts their enabled state, persisting in one flush. * * Unknown, non-ignored sets become known and disabled. * Ignored-known sets are enabled if they were previously disabled. + * Only changed sets are written; when nothing is unknown no flush happens at all. + * @param controller The set priority controller to persist the records with. */ - void markAllAsKnown(); + void markAllAsKnownBatched(ICardSetPriorityController *controller); /** * @brief Counts the number of sets that are currently enabled. diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h index 6b3e684d7..746ac9490 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h @@ -12,6 +12,7 @@ public: QString shortName; unsigned int sortKey; bool enabled; + bool isKnown = false; }; /** @@ -57,6 +58,14 @@ public: */ virtual QStringList getEnabledSetNames() const = 0; + /** + * @brief Persists per-set options for every entry in a single settings flush. + * + * Each individual setSortKey/setEnabled/setIsKnown call re-opens and re-syncs + * the whole .ini when backed by QSettings, so persisting hundreds of sets + * one option at a time freezes the GUI thread for seconds. Callers that touch + * more than a handful of sets must bundle the records and call this once. + */ virtual void saveSets(const QVector &data) = 0; }; diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp index 1bc1b9e38..a0b9c1061 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp @@ -266,7 +266,7 @@ void SetsModel::save(CardDatabase *db) const bool enabled = enabledSets.contains(sets[i]); sets[i]->setSortKeyInMemory(sortKey); sets[i]->setEnabledInMemory(enabled); - saveData.append({sets[i]->getShortName(), sortKey, enabled}); + saveData.append({sets[i]->getShortName(), sortKey, enabled, sets[i]->getIsKnown()}); } db->getPriorityController()->saveSets(saveData); diff --git a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp index acac1125f..49bcb042b 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp @@ -110,6 +110,7 @@ void CardDatabaseSettings::saveSets(const QVector &data) override { + ++batchedWrites; for (const SetSaveData &entry : data) { setOptions[entry.shortName].sortKey = entry.sortKey; setOptions[entry.shortName].enabled = entry.enabled; + setOptions[entry.shortName].isKnown = entry.isKnown; } } + int individualWrites = 0; ///< Per-set/per-option controller writes (the slow path). + int batchedWrites = 0; ///< Batched saveSets() flushes (the fast path). + private: QHash setOptions; }; @@ -375,6 +383,131 @@ TEST_F(CardDatabaseCacheInvalidationTest, ExplicitReloadBypassesCache) EXPECT_FALSE(db.getCardList().value("Cat")->getSets().contains("CAT")); EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("DOG")); } + +TEST_F(CardDatabaseCacheInvalidationTest, FirstRunWithNoSetsEnabledSkipsCacheWrite) +{ + // A blank config: the parser discovers every set, but none of them has an + // ini entry, so all of them default to disabled (first run). + SeededSetPriorityController blankController; + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &blankController); + + db.loadCardDatabases(); + ASSERT_FALSE(db.getCardList().value("Cat").isNull()) << "cards are parsed even while their sets are disabled"; + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().isEmpty()) + << "no printings survive an all-sets-disabled parse"; + EXPECT_TRUE(cacheContents().isEmpty()) << "a stripped first-run snapshot must not be cached"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, EnablingAllSetsAfterFirstRunRestoresPrintings) +{ + SeededSetPriorityController blankController; + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &blankController); + + db.loadCardDatabases(); + ASSERT_TRUE(cacheContents().isEmpty()); + + // This mirrors what checkUnknownSets() does on a first run: enable every + // set, then reload so the parser keeps the printings and the complete + // snapshot gets cached under the enabled-set hash. + for (const CardSetPtr &set : db.getSetList()) { + blankController.setEnabled(set->getShortName(), true); + } + db.reloadCardDatabasesAndNotify(); + + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT")); + EXPECT_FALSE(cacheContents().isEmpty()) << "the enabled-set snapshot must be cached"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, CheckUnknownSetsBatchesFirstRunEnableAll) +{ + // Same first-run starting point as above, but driven through the real + // checkUnknownSets() path, which must persist the enable-all with a single + // batched saveSets() instead of per-set/per-option controller writes. + SeededSetPriorityController blankController; + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &blankController); + + db.loadCardDatabases(); + ASSERT_TRUE(db.getSetList().getEnabledSetsNum() == 0); + + db.checkUnknownSets(); + + EXPECT_EQ(blankController.individualWrites, 0) << "first-run enable-all must not write per set"; + EXPECT_EQ(blankController.batchedWrites, 1) << "first-run enable-all must persist with a single flush"; + for (const CardSetPtr &set : db.getSetList()) { + EXPECT_TRUE(set->getEnabled()) << set->getShortName().toStdString(); + EXPECT_TRUE(blankController.isEnabled(set->getShortName())) << set->getShortName().toStdString(); + if (!set->getIsKnownIgnored()) { + EXPECT_TRUE(set->getIsKnown()) << set->getShortName().toStdString(); + EXPECT_TRUE(blankController.isKnown(set->getShortName())) << set->getShortName().toStdString(); + } + } + + // The follow-up reload re-parses with the sets enabled, restoring printings. + db.reloadCardDatabasesAndNotify(); + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT")); +} + +TEST_F(CardDatabaseCacheInvalidationTest, EnableAllUnknownSetsIsBatched) +{ + // The "new sets found" dialog path: enabling the unknown delta must persist + // with a single flush no matter how many sets the database holds, so users + // with hundreds of custom sets don't stall the GUI thread. Built directly + // on CardSet objects because the fixture sets carry no metadata (and would + // all count as ignored). + SeededSetPriorityController controller; + CardSetList sets; + sets.append( + CardSet::newInstance(&controller, "AAA", "Alpha Set", "core", QDate(2020, 1, 1), CardSet::PriorityPrimary)); + sets.append( + CardSet::newInstance(&controller, "BBB", "Beta Set", "expansion", QDate(2021, 6, 1), CardSet::PriorityPrimary)); + sets.append(CardSet::newInstance(&controller, "XXX")); // no metadata: ignored + + sets.enableAllUnknownBatched(&controller); + + EXPECT_EQ(controller.individualWrites, 0) << "unknown-set enable must not write per set"; + EXPECT_EQ(controller.batchedWrites, 1) << "unknown-set enable must persist with a single flush"; + for (const CardSetPtr &set : sets) { + EXPECT_TRUE(set->getEnabled()) << set->getShortName().toStdString(); + EXPECT_TRUE(controller.isEnabled(set->getShortName())) << set->getShortName().toStdString(); + if (set->getIsKnownIgnored()) { + EXPECT_FALSE(set->getIsKnown()) << set->getShortName().toStdString(); + } else { + EXPECT_TRUE(set->getIsKnown()) << set->getShortName().toStdString(); + EXPECT_TRUE(controller.isKnown(set->getShortName())) << set->getShortName().toStdString(); + } + } +} + +TEST_F(CardDatabaseCacheInvalidationTest, MarkAllSetsAsKnownIsBatched) +{ + // The dialog's "No" path: same single-flush requirement as enabling. + SeededSetPriorityController controller; + CardSetList sets; + sets.append( + CardSet::newInstance(&controller, "AAA", "Alpha Set", "core", QDate(2020, 1, 1), CardSet::PriorityPrimary)); + sets.append( + CardSet::newInstance(&controller, "BBB", "Beta Set", "expansion", QDate(2021, 6, 1), CardSet::PriorityPrimary)); + sets.append(CardSet::newInstance(&controller, "XXX")); // no metadata: ignored + + sets.markAllAsKnownBatched(&controller); + + EXPECT_EQ(controller.individualWrites, 0) << "mark-known must not write per set"; + EXPECT_EQ(controller.batchedWrites, 1) << "mark-known must persist with a single flush"; + EXPECT_TRUE(controller.isKnown("AAA")); + EXPECT_TRUE(controller.isKnown("BBB")); + EXPECT_FALSE(controller.isEnabled("AAA")); + EXPECT_FALSE(controller.isEnabled("BBB")); + // Ignored sets keep their known state but are enabled. + EXPECT_TRUE(controller.isEnabled("XXX")); +} } // namespace int main(int argc, char **argv) From 24874b10cf527a461cd157d51c9acad472117ab2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:45:08 +0200 Subject: [PATCH 023/247] [GameSelector] Persist quick-filter toolbar edits and repair filter settings migration (#7500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [GameSelector] Persist quick-filter toolbar edits and repair filter settings migration - Save game-filter parameters on toolbar changes, debounced with flush on close - Repair pre-camelCase gamefilters.ini keys in place on startup - Map the legacy creator-name filter to hostNameFilter - Balance begin/endFilterChange in GamesProxyModel::refresh - Align the hideIgnoredUserGames default between model and settings - Cover the migration with regression tests * [Review] Address RickyRister comments on filter persistence - Rename game-type prefix constants to SCREAMING_SNAKE to match MIGRATION_SENTINEL_KEY and project style - Document why GameSelector destructor flushes a pending debounced filter save (toolbar path has no other writer) --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/cache_settings.cpp | 4 + .../widgets/server/game_filter_configs.h | 4 +- .../widgets/server/game_selector.cpp | 23 ++++ .../interface/widgets/server/game_selector.h | 5 + .../interface/widgets/server/games_model.cpp | 3 + .../settings/settings_migration.cpp | 113 +++++++++++++++--- .../settings/settings_migration.h | 9 ++ tests/settings/settings_migration_test.cpp | 65 ++++++++++ 8 files changed, 207 insertions(+), 19 deletions(-) diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 15a41e7e1..08eccf1e4 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -133,6 +133,10 @@ SettingsCache::SettingsCache() // Migrate settings from global.ini to per-class INI files (runs at most once) SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + // Repair gamefilters.ini keys renamed to camelCase by #7084 for profiles that migrated + // before that rename (idempotent, safe to run on every startup, portable builds included). + SettingsMigration::migrateGameFiltersToCamelCase(settingsPath); + shortcutsSettings = new ShortcutsSettings(settingsPath, this); cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this); serversSettings = new ServersSettings(settingsPath, this); diff --git a/cockatrice/src/interface/widgets/server/game_filter_configs.h b/cockatrice/src/interface/widgets/server/game_filter_configs.h index 1b7322c55..93ca010d8 100644 --- a/cockatrice/src/interface/widgets/server/game_filter_configs.h +++ b/cockatrice/src/interface/widgets/server/game_filter_configs.h @@ -17,7 +17,9 @@ struct GameFilterConfigs static constexpr int DEFAULT_MAX_PLAYERS_MAX = 99; bool hideBuddiesOnlyGames = false; - bool hideIgnoredUserGames = false; + // Defaults to true to match GameFiltersSettings::isHideIgnoredUserGames(), so freshly loaded + // filters compare equal to a default-constructed config. + bool hideIgnoredUserGames = true; bool hideFullGames = false; bool hideGamesThatStarted = false; bool hidePasswordProtectedGames = false; diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 27464e14e..6922bfe64 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -113,6 +113,17 @@ GameSelector::GameSelector(AbstractClient *_client, quickFilterToolBar->setVisible( SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar()); }); + // The quick-filter toolbar only updates the in-memory model, so persist every change. + // Otherwise each new GameSelector (room switch, reconnect, restart) would revert to stale + // saved values and e.g. hide full/started games the user had unchecked. The save is + // debounced so typing in the search field does not rewrite the settings file on every + // keystroke; a pending save is flushed in the destructor. + filterSaveDebounceTimer.setSingleShot(true); + filterSaveDebounceTimer.setInterval(500); + connect(&filterSaveDebounceTimer, &QTimer::timeout, this, + [this] { gameListProxyModel->saveFilterParameters(gameTypeMap); }); + connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this, + [this] { filterSaveDebounceTimer.start(); }); } else { quickFilterToolBar = nullptr; } @@ -187,6 +198,18 @@ GameSelector::GameSelector(AbstractClient *_client, connect(client, &AbstractClient::removeFromListEventReceived, this, &GameSelector::processRemoveFromListEvent); } +GameSelector::~GameSelector() +{ + // Nothing else persists quick-filter toolbar edits: the toolbar only updates the in-memory model, + // and the debounced timer above is the sole writer for that path (actSetFilter/actClearFilter save + // synchronously, but the toolbar does not). Flush a pending debounced save so edits made within + // the debounce interval before destruction are not lost on room switch, reconnect, or close. + if (filterSaveDebounceTimer.isActive()) { + filterSaveDebounceTimer.stop(); + gameListProxyModel->saveFilterParameters(gameTypeMap); + } +} + void GameSelector::ignoreListReceived(const QList &) { gameListProxyModel->refresh(); diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index 01e093b06..e3407abea 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -4,6 +4,7 @@ #include "game_type_map.h" #include +#include #include // IWYU pragma: keep // ServerInfo_User appears by value and inside QList in slot signatures, so the @@ -148,6 +149,9 @@ private: const bool showFilters; /**< Determines whether filter buttons are displayed. */ GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */ + QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings + file on every keystroke. */ + /** * @brief Updates the widget title to reflect the current number of displayed games. * @@ -211,6 +215,7 @@ public: const bool restoresettings, const bool _showfilters, QWidget *parent = nullptr); + ~GameSelector() override; /** * @brief Updates UI text for translation/localization. diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 289dbee44..315d51d9e 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -523,6 +523,9 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const void GamesProxyModel::refresh() { +#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)) + beginFilterChange(); +#endif #if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)) endFilterChange(QSortFilterProxyModel::Direction::Rows); #else diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp index c3f299f40..d9652cde3 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -642,6 +642,34 @@ static void migrateLegacyMessages(const QString &settingsPath) legacySetting.endGroup(); } +/** + * gamefilters.ini keys renamed to camelCase by #7084. Profiles that migrated from the legacy + * native store before that rename still hold the old snake_case keys, which nothing reads + * anymore, so their saved filters silently stopped loading. + */ +static const QMap &renamedGameFilterKeys() +{ + static const QMap keyMap = { + {"hide_buddies_only_games", "hideBuddiesOnlyGames"}, + {"hide_full_games", "hideFullGames"}, + {"hide_games_that_started", "hideGamesThatStarted"}, + {"hide_password_protected_games", "hidePasswordProtectedGames"}, + {"hide_ignored_user_games", "hideIgnoredUserGames"}, + {"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"}, + {"hide_open_decklist_games", "hideOpenDecklistGames"}, + {"game_name_filter", "gameNameFilter"}, + {"creator_name_filter", "hostNameFilter"}, + {"min_players", "minPlayers"}, + {"max_players", "maxPlayers"}, + {"max_game_age_time", "maxGameAgeTime"}, + {"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"}, + {"show_spectator_password_protected", "showSpectatorPasswordProtected"}, + {"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"}, + {"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"}, + }; + return keyMap; +} + static void migrateLegacyGameFilters(const QString &settingsPath) { QSettings legacySetting; @@ -652,24 +680,7 @@ static void migrateLegacyGameFilters(const QString &settingsPath) return; } - const QMap filterKeyMap = { - {"hide_buddies_only_games", "hideBuddiesOnlyGames"}, - {"hide_full_games", "hideFullGames"}, - {"hide_games_that_started", "hideGamesThatStarted"}, - {"hide_password_protected_games", "hidePasswordProtectedGames"}, - {"hide_ignored_user_games", "hideIgnoredUserGames"}, - {"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"}, - {"hide_open_decklist_games", "hideOpenDecklistGames"}, - {"game_name_filter", "gameNameFilter"}, - {"creator_name_filter", "creatorNameFilter"}, - {"min_players", "minPlayers"}, - {"max_players", "maxPlayers"}, - {"max_game_age_time", "maxGameAgeTime"}, - {"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"}, - {"show_spectator_password_protected", "showSpectatorPasswordProtected"}, - {"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"}, - {"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"}, - }; + const QMap &filterKeyMap = renamedGameFilterKeys(); QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) { @@ -686,6 +697,68 @@ static void migrateLegacyGameFilters(const QString &settingsPath) legacySetting.endGroup(); } +static const QString LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/game_type/"); +static const QString GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/gameType/"); + +static bool gameFiltersCamelCaseMigrationPending(const QString &settingsPath) +{ + if (!QFile::exists(settingsPath + "gamefilters.ini")) { + return false; + } + + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + const QMap &keyMap = renamedGameFilterKeys(); + for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) { + if (filtersIni.contains("filter_games/" + it.key()) && !filtersIni.contains("filter_games/" + it.value())) { + return true; + } + } + // Orphaned by #7084, which migrated the legacy host-name filter to a key nothing reads. + if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) { + return true; + } + for (const auto &key : filtersIni.allKeys()) { + if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX) && + !filtersIni.contains(GAME_FILTERS_GAME_TYPE_PREFIX + + key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size()))) { + return true; + } + } + return false; +} + +bool SettingsMigration::migrateGameFiltersToCamelCase(const QString &settingsPath) +{ + if (!gameFiltersCamelCaseMigrationPending(settingsPath)) { + return false; + } + + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + const QMap &keyMap = renamedGameFilterKeys(); + for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) { + const QString oldKey = "filter_games/" + it.key(); + const QString newKey = "filter_games/" + it.value(); + if (filtersIni.contains(oldKey) && !filtersIni.contains(newKey)) { + filtersIni.setValue(newKey, filtersIni.value(oldKey)); + } + } + if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) { + filtersIni.setValue("filter_games/hostNameFilter", filtersIni.value("filter_games/creatorNameFilter")); + } + for (const auto &key : filtersIni.allKeys()) { + if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX)) { + const QString newKey = GAME_FILTERS_GAME_TYPE_PREFIX + key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size()); + if (!filtersIni.contains(newKey)) { + filtersIni.setValue(newKey, filtersIni.value(key)); + } + } + } + // The old keys are deliberately kept: pre-#7084 builds still read them, so removing them + // would break downgrades, and nothing in the current code reads them anymore. + filtersIni.sync(); + return true; +} + bool SettingsMigration::migrateLegacySettings(const QString &settingsPath) { QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); @@ -744,6 +817,10 @@ bool SettingsMigration::isMigrationPending(const QString &settingsPath) return true; } + if (gameFiltersCamelCaseMigrationPending(settingsPath)) { + return true; + } + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); if (personalIni.value("migration/legacy_complete", false).toBool()) { return false; diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.h b/libcockatrice_settings/libcockatrice/settings/settings_migration.h index 78f9c8a3f..0a706af98 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.h @@ -9,6 +9,15 @@ public: static bool migrateSettingsFromGlobalIni(const QString &settingsPath); static bool migrateLegacySettings(const QString &settingsPath); + /** + * Moves pre-camelCase gamefilters.ini keys (snake_case, game_type/) to the camelCase + * keys used since #7084, for profiles that already migrated before that rename and therefore + * never pass through migrateLegacySettings again. Copies values only where the new key is + * absent, never deletes the old keys, and returns true when anything was copied. Idempotent, + * so it runs on every startup. + */ + static bool migrateGameFiltersToCamelCase(const QString &settingsPath); + /** * Returns true when at least one of the two migrations would still do work, i.e. when a * backup of the settings directory is worth taking before they run. diff --git a/tests/settings/settings_migration_test.cpp b/tests/settings/settings_migration_test.cpp index 440e49b74..2c4dcf7a9 100644 --- a/tests/settings/settings_migration_test.cpp +++ b/tests/settings/settings_migration_test.cpp @@ -705,6 +705,71 @@ TEST_F(SettingsMigrationTest, LegacyMigrationCamelCasesKeys) ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true)); } +TEST_F(SettingsMigrationTest, LegacyMigrationMapsCreatorNameFilterToHostNameFilter) +{ + { + QSettings nativeSettings; + nativeSettings.setValue("filter_games/creator_name_filter", QStringList({"Alice", "Bob"})); + nativeSettings.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + + ASSERT_TRUE(fileExists("gamefilters.ini")); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice", "Bob"}))); +} + +TEST_F(SettingsMigrationTest, GameFiltersIniSnakeCaseMigratesToCamelCase) +{ + { + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + filtersIni.setValue("filter_games/hide_full_games", true); + filtersIni.setValue("filter_games/hide_games_that_started", true); + filtersIni.setValue("filter_games/game_name_filter", "test game"); + filtersIni.setValue("filter_games/creator_name_filter", QStringList({"Alice"})); + filtersIni.setValue("filter_games/game_type/deadbeef", true); + filtersIni.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); + + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(true)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideGamesThatStarted"), QVariant(true)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameNameFilter"), QVariant("test game")); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice"}))); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true)); + + // Idempotent: a second run has nothing left to do + ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); +} + +TEST_F(SettingsMigrationTest, GameFiltersIniOrphanCreatorNameFilterMigratesToHostName) +{ + { + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + filtersIni.setValue("filter_games/creatorNameFilter", QStringList({"Alice"})); + filtersIni.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice"}))); + ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); +} + +TEST_F(SettingsMigrationTest, GameFiltersIniKeepsNewerCamelCaseValues) +{ + { + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + filtersIni.setValue("filter_games/hide_full_games", true); + filtersIni.setValue("filter_games/hideFullGames", false); + filtersIni.sync(); + } + + // Nothing to do: the current-format key already exists and must win + ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(false)); +} + TEST_F(SettingsMigrationTest, LegacyMigrationEmptyNativeFormatWritesSentinel) { QSettings().clear(); From cce606764c1a3b6c1756eb0904fcd42f951daa47 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:23:13 +0200 Subject: [PATCH 024/247] [Server] Resolve a zone dump request to an in-range index span (#7401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command_DumpZone.number_cards is an unbounded sint32 supplied by the client, and cmdDumpZone used it as an index without ever putting it in range. for (int i = 0; (i < cards.size()) && (i < numberCards || numberCards == -1); ++i) { const auto &findId = cmd.is_reversed() ? cards.size() - numberCards + i : i; Server_Card *card = cards[findId]; A forward request happened to be safe, because the loop stopped at the zone's own size. A reversed one was not. number_cards is documented by the client as -1 for "the whole zone", and cards.size() - numberCards is therefore cardCount + 1, one past the last card. A request larger than the zone puts the offset at a negative index instead. Both read out of bounds, then dereference the result as a Server_Card, so either adjacent heap memory or a crash. Any player can reach it: the zone only has to be a public one, or the requester's own, and both number_cards and is_reversed come straight off the wire. A reversed dump of -1 is enough on any public zone with cards in it. Resolve the request to a half-open span up front. -1 and any positive count are clamped to what the zone actually holds, any other negative count resolves to nothing, and a reversed request takes the span from the end rather than computing an offset from a value that may not be in range. cards.at() then carries the bound as a second line of defence, and qsizetype keeps the loop and the list's own indexing type aligned. Also report card_count as the zone's real size. It was numberCards < cards.size() ? cards.size() : numberCards, which agrees with the zone size for every request that was in range and reported the client's own oversized number otherwise. The client uses card_count to draw placeholders when a zone lists no cards, so overstating it invents cards that were never there. Put the resolution in its own translation unit. server_abstract_player.cpp refers to the RNG global that only servatrice's main.cpp defines, so anything linking that object needs the whole server binary behind it, which is why no test could reach this code before. server_dump_zone_test covers each request shape and then asserts the span is contained in [0, cardCount) across the edges of the request domain, INT_MIN and INT_MAX included. Reverting the arithmetic makes three of them fail, the two out-of-bounds shapes and the containment sweep. The client's own copy of this convention at view_zone.cpp:93 already clamps the way this now does. Leaving that duplication for a follow-up, since the client reaching the same answer by a different route is not a defect. Co-authored-by: Lukas Brübach --- .../network/server/remote/CMakeLists.txt | 2 + .../server/remote/game/dump_zone_range.cpp | 17 +++ .../server/remote/game/dump_zone_range.h | 29 ++++ .../remote/game/server_abstract_player.cpp | 11 +- tests/CMakeLists.txt | 11 ++ tests/server_dump_zone_test.cpp | 125 ++++++++++++++++++ 6 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.cpp create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.h create mode 100644 tests/server_dump_zone_test.cpp diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 5840f80c0..2127487d2 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -3,6 +3,7 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(HEADERS + game/dump_zone_range.h game/server_abstract_participant.h game/server_abstract_player.h game/server_arrow.h @@ -33,6 +34,7 @@ qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_network_server_remote STATIC ${MOC_SOURCES} + game/dump_zone_range.cpp game/server_abstract_participant.cpp game/server_abstract_player.cpp game/server_arrow.cpp diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.cpp new file mode 100644 index 000000000..4ecca95cb --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.cpp @@ -0,0 +1,17 @@ +#include "dump_zone_range.h" + +#include + +std::pair dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed) +{ + qsizetype count = 0; + if (numberCards == -1) { + count = cardCount; + } else if (numberCards > 0) { + count = std::min(numberCards, cardCount); + } + count = std::max(count, 0); + + const qsizetype begin = isReversed ? cardCount - count : 0; + return {begin, begin + count}; +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.h b/libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.h new file mode 100644 index 000000000..a688b09d0 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/dump_zone_range.h @@ -0,0 +1,29 @@ +#ifndef DUMP_ZONE_RANGE_H +#define DUMP_ZONE_RANGE_H + +#include +#include + +/** + * @brief Resolve a client's zone dump request against a zone's real card count. + * + * @c number_cards arrives from the client unvalidated. -1 means "the whole zone", which + * is the convention the client uses when sizing its own view of one. Every other negative + * value, and every value larger than the zone, has to be brought into range rather than + * used as an index: with a reversed request the zone offset is + * @c cardCount - @c numberCards, which runs off the front of the list when the request + * exceeds the zone and off the back when @c numberCards is -1. + * + * Kept in its own translation unit so it can be exercised without the rest of + * server_abstract_player.cpp, which refers to the RNG global that only the servatrice + * binary defines. + * + * @param cardCount number of cards the zone actually holds + * @param numberCards the requested count, -1 for the whole zone + * @param isReversed take the trailing cards instead of the leading ones + * @return the half-open index range [begin, end) into the zone's card list, always + * contained in [0, cardCount) + */ +std::pair dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed); + +#endif // DUMP_ZONE_RANGE_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index 47c810d0d..c03f1e966 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -1,5 +1,6 @@ #include "server_abstract_player.h" +#include "dump_zone_range.h" #include "game/server_abstract_participant.h" #include "game/server_arrowtarget.h" #include "libcockatrice/protocol/pb/card_attributes.pb.h" @@ -1435,23 +1436,23 @@ Server_AbstractPlayer::cmdDumpZone(const Command_DumpZone &cmd, ResponseContaine int numberCards = cmd.number_cards(); const QList &cards = zone->getCards(); + const auto cardRange = dumpZoneCardRange(cards.size(), numberCards, cmd.is_reversed()); auto *re = new Response_DumpZone; ServerInfo_Zone *zoneInfo = re->mutable_zone_info(); zoneInfo->set_name(zone->getName().toStdString()); zoneInfo->set_type(zone->getType()); zoneInfo->set_with_coords(zone->hasCoords()); - zoneInfo->set_card_count(numberCards < cards.size() ? cards.size() : numberCards); + zoneInfo->set_card_count(cards.size()); - for (int i = 0; (i < cards.size()) && (i < numberCards || numberCards == -1); ++i) { - const auto &findId = cmd.is_reversed() ? cards.size() - numberCards + i : i; - Server_Card *card = cards[findId]; + for (qsizetype findId = cardRange.first; findId < cardRange.second; ++findId) { + Server_Card *card = cards.at(findId); QString displayedName = card->getFaceDown() ? QString() : card->getName(); ServerInfo_Card *cardInfo = zoneInfo->add_card_list(); cardInfo->set_provider_id(card->getProviderId().toStdString()); cardInfo->set_name(displayedName.toStdString()); if (zone->getType() == ServerInfo_Zone::HiddenZone) { - cardInfo->set_id(findId); + cardInfo->set_id(static_cast(findId)); } else { cardInfo->set_id(card->getId()); cardInfo->set_x(card->getX()); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 22dbedb98..9955e1189 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -206,6 +206,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_dump_zone_test + SOURCES + server_dump_zone_test.cpp + LIBS + libcockatrice_rng + libcockatrice_network + LABELS + server +) cockatrice_add_test( NAME server_counter_test diff --git a/tests/server_dump_zone_test.cpp b/tests/server_dump_zone_test.cpp new file mode 100644 index 000000000..71c410655 --- /dev/null +++ b/tests/server_dump_zone_test.cpp @@ -0,0 +1,125 @@ +/** @file server_dump_zone_test.cpp + * @brief Tests for resolving a client's zone dump request against a zone's card count. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include + +namespace +{ +/// Indices a dump request resolves to, as the handler would walk them. +std::vector walked(qsizetype cardCount, int numberCards, bool isReversed) +{ + const auto [begin, end] = dumpZoneCardRange(cardCount, numberCards, isReversed); + std::vector out; + for (qsizetype i = begin; i < end; ++i) { + out.push_back(i); + } + return out; +} +} // namespace + +TEST(ServerDumpZone, ForwardTakesLeadingCards) +{ + EXPECT_EQ(walked(5, 2, false), (std::vector{0, 1})); +} + +TEST(ServerDumpZone, ForwardTakingAllCardsCoversTheZone) +{ + EXPECT_EQ(walked(3, 3, false), (std::vector{0, 1, 2})); +} + +TEST(ServerDumpZone, ForwardAskingForOneDumpsOne) +{ + EXPECT_EQ(walked(3, 1, false), (std::vector{0})); +} + +TEST(ServerDumpZone, MinusOneMeansTheWholeZone) +{ + EXPECT_EQ(walked(4, -1, false), (std::vector{0, 1, 2, 3})); +} + +TEST(ServerDumpZone, ReversedTakesTrailingCards) +{ + EXPECT_EQ(walked(5, 2, true), (std::vector{3, 4})); +} + +TEST(ServerDumpZone, ReversedAskingForOneTakesTheLast) +{ + EXPECT_EQ(walked(5, 1, true), (std::vector{4})); +} + +TEST(ServerDumpZone, ZeroDumpsNothing) +{ + EXPECT_TRUE(walked(5, 0, false).empty()); + EXPECT_TRUE(walked(5, 0, true).empty()); +} + +// A request for more cards than the zone holds is clamped rather than walked off the end. +// Forward, the old loop stopped at the zone's size on its own. +TEST(ServerDumpZone, ForwardOversizedRequestIsClamped) +{ + EXPECT_EQ(walked(3, 1000, false), (std::vector{0, 1, 2})); +} + +// Reversed, an oversized request put the offset at cardCount - numberCards, which is +// negative, so the walk started before the first card. +TEST(ServerDumpZone, ReversedOversizedRequestIsClamped) +{ + EXPECT_EQ(walked(3, 1000, true), (std::vector{0, 1, 2})); +} + +// Reversed, -1 put the offset at cardCount + 1, so the walk started one past the last card. +TEST(ServerDumpZone, ReversedWholeZoneRequestStaysInRange) +{ + EXPECT_EQ(walked(3, -1, true), (std::vector{0, 1, 2})); +} + +// Only -1 means "the whole zone". Any other negative count is not a request for anything, +// so it resolves to an empty range rather than to the whole zone. +TEST(ServerDumpZone, OtherNegativeCountsDumpNothing) +{ + EXPECT_TRUE(walked(5, -2, false).empty()); + EXPECT_TRUE(walked(5, -2, true).empty()); + EXPECT_TRUE(walked(5, INT_MIN, true).empty()); +} + +TEST(ServerDumpZone, EmptyZoneYieldsAnEmptyRange) +{ + EXPECT_TRUE(walked(0, -1, false).empty()); + EXPECT_TRUE(walked(0, -1, true).empty()); + EXPECT_TRUE(walked(0, 10, false).empty()); + EXPECT_TRUE(walked(0, 10, true).empty()); +} + +TEST(ServerDumpZone, NonPositiveCardCountYieldsAnEmptyRange) +{ + EXPECT_TRUE(walked(-1, -1, false).empty()); + EXPECT_TRUE(walked(-1, -1, true).empty()); +} + +// The invariant the handler depends on: whatever a client asks for, the indices it walks +// are a subset of the zone's cards and never step outside the list. number_cards is an +// unbounded client-supplied sint32, so this sweeps the edges rather than a chosen few. +TEST(ServerDumpZone, ResolvedRangeIsAlwaysWithinTheZone) +{ + const QList cardCounts = {0, 1, 2, 3, 7, 60, 1000000}; + const QList requests = {INT_MIN, -1000, -2, -1, 0, 1, 2, 3, 59, 60, 61, 999999, INT_MAX}; + + for (int cardCount : cardCounts) { + for (int request : requests) { + for (bool isReversed : {false, true}) { + const auto [begin, end] = dumpZoneCardRange(cardCount, request, isReversed); + SCOPED_TRACE(testing::Message() + << "cardCount=" << cardCount << " request=" << request << " isReversed=" << isReversed); + ASSERT_GE(begin, 0); + ASSERT_GE(end, begin); + ASSERT_LE(end, cardCount); + } + } + } +} From 25c85cf0bb818eced0a19ae09f785abe4a86aa47 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:14:16 +0200 Subject: [PATCH 025/247] [CI] Key the vcpkg binary cache per ABI and let pull requests heal it (#7377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Key the vcpkg binary cache per ABI and let pull requests heal it The cache had no prefix, and TAServers/vcpkg-cache restores every entry it finds under the prefix rather than only the hashes vcpkg asks for. With all legs sharing the single vcpkg/ namespace, each of the five of them downloads all 66 archives of all five on every run. Each restore also refreshes the entry's last access date, so a dead ABI generation stays fresh forever and GitHub's least recently used eviction can never reach it. Name the scope in the key after the platform, target and toolchain it belongs to, so a leg restores only its own archives. macOS 14, 15 and 26 on the same Xcode build identical binaries when they share the lowest deployment target, so point all three at 14 and let them share one scope instead of three. A pull request cannot write the shared cache either. Its entries are scoped to refs/pull//merge, so nothing it saves is ever readable again, and letting it write anyway only burns the repository's quota, which is what the previous attempt at this did. Keep the build read-only and hand the archives it had to build itself to a new workflow running in the default branch scope, which saves them where master can restore them. Forks stay read-only, they are the one case whose archives must not be trusted. Because the prefix changes every key, add a job that deletes the entries of retired scopes, which is the one-time clean up of the current flat namespace, and the per-run ones pull requests leave behind, which age based eviction also never reaches. Also pin the Linux ccache cache to its own subdirectory, since the directory is shared with the docker build context, and drop the clang-format install from the build images, which format nothing at runtime. * [CI] Create the vcpkg cache directory when a scope is still empty * [CI] Let a pull request write the vcpkg archives it builds * [CI] Upload the vcpkg handover as one archive instead of loose files * [CI] Hand the vcpkg cache over from one leg per scope * [CI] Gate vcpkg cache writes to master pushes --------- Co-authored-by: Lukas Brübach --- .ci/Debian12/Dockerfile | 1 - .ci/Debian13/Dockerfile | 1 - .ci/Servatrice_Debian12/Dockerfile | 1 - .ci/Ubuntu24.04/Dockerfile | 1 - .ci/Ubuntu26.04/Dockerfile | 1 - .ci/compile.sh | 12 +- .github/workflows/desktop-build.yml | 166 ++++++++++++++++++++++- .github/workflows/vcpkg-cache-import.yml | 121 +++++++++++++++++ 8 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/vcpkg-cache-import.yml diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index fc756aac2..14c6f018c 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -4,7 +4,6 @@ RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ ccache \ - clang-format \ cmake \ file \ g++ \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index bdecb56df..b597307c5 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -5,7 +5,6 @@ RUN apt-get update && \ build-essential \ ca-certificates \ ccache \ - clang-format \ cmake \ file \ g++ \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index 321aa7c0f..f1a16843a 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -4,7 +4,6 @@ RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ ccache \ - clang-format \ cmake \ file \ g++ \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 715997474..b9fb2b701 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -4,7 +4,6 @@ RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ ccache \ - clang-format \ cmake \ file \ g++ \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 3166b7eb4..cc6528858 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -5,7 +5,6 @@ RUN apt-get update && \ build-essential \ ca-certificates \ ccache \ - clang-format \ cmake \ file \ g++ \ diff --git a/.ci/compile.sh b/.ci/compile.sh index 606b9d201..302972f30 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -17,7 +17,7 @@ # --dir sets the name of the build dir, default is "build" # --cmake-generator sets CMAKE_GENERATOR as used by cmake # --target-macos-version sets the min os version - only used for macOS builds -# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE +# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION # (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir ) # exitcode: 1 for failure, 3 for invalid arguments @@ -243,8 +243,14 @@ if [[ $RUNNER_OS == macOS ]]; then triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" fi cp "$triplet_source" "$triplet_file" - echo "set(VCPKG_CMAKE_SYSTEM_VERSION $TARGET_MACOS_VERSION)" >>"$triplet_file" - echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $TARGET_MACOS_VERSION)" >>"$triplet_file" + # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that + # agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode + # produce identical binaries when they share the lowest deployment target, so they all + # build against 14 to keep one vcpkg cache scope instead of three. + # Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml. + vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}" + echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file" + echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file" flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir") flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version") flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 098aabc4f..1ce51afaa 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -184,7 +184,7 @@ jobs: uses: actions/cache/restore@v6 with: key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} - path: ${{ env.CACHE }} + path: ${{ env.CACHE }}/.ccache - name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image" shell: bash @@ -267,7 +267,7 @@ jobs: uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} - path: ${{ env.CACHE }} + path: ${{ env.CACHE }}/.ccache - name: "Upload artifact" id: upload_artifact @@ -325,6 +325,8 @@ jobs: soc: Intel type: Release use_ccache: 1 + vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' + vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over xcode: "26.3" - os: macOS @@ -341,6 +343,9 @@ jobs: soc: Apple type: Release use_ccache: 1 + vcpkg_deployment_target: 14 + vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' + vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over xcode: "26.6" - os: macOS @@ -357,6 +362,8 @@ jobs: soc: Apple type: Release use_ccache: 1 + vcpkg_deployment_target: 14 + vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' xcode: "26.6" - os: macOS @@ -370,6 +377,8 @@ jobs: soc: Apple type: Debug use_ccache: 1 + vcpkg_deployment_target: 14 + vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' xcode: "26.6" - os: Windows @@ -383,6 +392,8 @@ jobs: qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release + vcpkg_scope: 'vcpkg/windows10-x64-vs2026/' + vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} needs: configure @@ -480,8 +491,28 @@ jobs: id: vcpkg-cache uses: TAServers/vcpkg-cache@v3 with: + # Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform, + # target and toolchain. The action restores every entry it finds under the + # prefix, not just the hashes vcpkg asks for, so with one shared vcpkg/ + # namespace every leg downloads all 66 archives of all 5 legs on every run. + # Each of those restores also refreshes the entry's last access date, which + # is why a dead ABI generation is never evicted by age. Naming the scope is + # what makes those generations identifiable, and prune-caches below able to + # delete them. + prefix: ${{ matrix.vcpkg_scope }} token: ${{ secrets.GITHUB_TOKEN }} + # The action only creates its directory when the prefix matched at least one + # entry, and it reads that directory unguarded when it saves at the end of the + # 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. + - name: "Create vcpkg cache directory" + shell: bash + env: + CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}' + run: mkdir -p "$CACHE_DIR" + # Uses environment variables, see compile.sh for more details - name: "Build Cockatrice" id: build @@ -501,9 +532,19 @@ jobs: PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' TARGET_MACOS_VERSION: ${{ matrix.override_target }} USE_CCACHE: ${{ matrix.use_ccache }} + # Only pushes to the default branch may write the shared binary cache: the + # archives a run compiles are only as trustworthy as the code they were + # built from, and only merged code may populate the scope that master and + # release builds restore and link. Pull requests (same-repository ones + # included) restore the shared cache but never write it; with the source + # left at read the collect step below finds nothing on PRs, so no + # pull-request-scoped entry is ever saved that no later run could restore. + # Forks pushing to their own default branch resolve github.ref against + # their own repository, so they keep a working cache of their own there. VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}" VCPKG_DISABLE_METRICS: 1 VCPKG_FEATURE_FLAGS: dependencygraph + VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }} run: .ci/compile.sh --server --test --vcpkg # CPack reports a failed makensis run as "Problem running NSIS command" @@ -517,6 +558,63 @@ jobs: shell: bash run: find . -name NSISOutput.log -exec cat {} + + # A master run hands over the archives it had to build itself so that + # vcpkg-cache-import.yml, which runs in the default branch scope, can save them + # where later runs can restore them. Pull requests hand over nothing: their cache + # entries would be scoped to refs/pull//merge and stay unreadable for every + # other run, so writing them only burns the repository's quota. + # vcpkg_upload keeps this to one leg per scope: macOS 14, 15 and 26 share a scope + # by design, and all three rebuild the same archives, so uploading from each of them + # would publish three identical 269 MB artifacts under one artifact name and make + # the importer spawn a save job per copy. + - name: "Collect new vcpkg entries" + id: vcpkg_new + if: github.ref == 'refs/heads/master' && matrix.vcpkg_upload == 1 + shell: bash + env: + CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}' + GH_TOKEN: ${{ github.token }} + VCPKG_SCOPE: '${{ matrix.vcpkg_scope }}' + run: | + # The archive name is the ABI hash, so a missing key is exactly what vcpkg had + # to compile from source in this job. + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/caches?ref=refs/heads/master&key=$VCPKG_SCOPE&per_page=100" \ + --jq '.actions_caches[].key' | sort -u >"$RUNNER_TEMP/master-keys.txt" + find "$CACHE_DIR" -type f -name '*.zip' -exec basename {} .zip \; | + sed "s|^|$VCPKG_SCOPE|" | sort -u >"$RUNNER_TEMP/local-keys.txt" + comm -23 "$RUNNER_TEMP/local-keys.txt" "$RUNNER_TEMP/master-keys.txt" >"$RUNNER_TEMP/new-keys.txt" + + mkdir -p "$RUNNER_TEMP/vcpkg-new" + : >"$RUNNER_TEMP/vcpkg-new/keys.txt" + while read -r key; do + hash="${key##*/}" + archive="$(find "$CACHE_DIR" -type f -name "$hash.zip" -print -quit)" + dir="$RUNNER_TEMP/vcpkg-new/${hash:0:2}" # vcpkg's own two character fanout + mkdir -p "$dir" + cp "$archive" "$dir/" + echo "$key ${hash:0:2}/$hash.zip" >>"$RUNNER_TEMP/vcpkg-new/keys.txt" + done <"$RUNNER_TEMP/new-keys.txt" + + count="$(wc -l <"$RUNNER_TEMP/new-keys.txt")" + echo "vcpkg scope $VCPKG_SCOPE: $count entries to import" + echo "count=$count" >>"$GITHUB_OUTPUT" + echo "slug=$(sed -e 's|^vcpkg/||' -e 's|/$||' -e 's|/|-|g' <<<"$VCPKG_SCOPE")" >>"$GITHUB_OUTPUT" + + # archive: false accepts a single file and nothing else, and a scope hands over one + # archive per ABI hash vcpkg had to rebuild, which is 23 files on a cold runner. + # Zipping the directory keeps the fanout at the artifact root, which is where + # vcpkg-cache-import.yml looks for keys.txt once download-artifact unpacks it. + - name: "Upload new vcpkg entries" + # An empty count means the step above did not run at all, on master and on forks + if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0' + uses: actions/upload-artifact@v7 + with: + if-no-files-found: error # a count above zero with nothing staged is a bug + name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }} + path: ${{ runner.temp }}/vcpkg-new + retention-days: 1 + # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "[macOS] Delete remote compiler cache (ccache)" if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit @@ -629,3 +727,67 @@ jobs: BUILD_PATH: ${{ steps.build.outputs.path }} GH_TOKEN: ${{ github.token }} run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice + + prune-caches: + name: Prune caches + # Only master writes caches at all, so only master may clean them up too + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + continue-on-error: true # freeing space must never fail a build + + steps: + # The repository holds 10Gi of cache against entries that want more, and GitHub + # evicts the least recently used entry whenever that is exceeded. The vcpkg + # entries are the reason that never happens: the restore above touches every + # entry under the prefix on every run, so a dead ABI generation keeps its access + # date fresh forever and age based eviction can never reach it. Deleting by age + # alone cannot fix that, only knowing which scope an entry belongs to can. + - name: "Delete unusable cache entries" + shell: bash + env: + GH_TOKEN: ${{ github.token }} + # Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of + # a scope that is still in use get deleted on the next run. + LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' + run: | + gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ + --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" + + cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))" + live=" ${LIVE_VCPKG_SCOPES} " + deleted=0 + while IFS=$'\t' read -r id ref key accessed; do + [[ -n $id ]] || continue + # Only ever touch the key shapes this workflow creates, so that an entry of + # some other workflow can never be deleted by accident + case "$key" in + vcpkg/* | ccache-* | Qt*) ;; + *) continue ;; + esac + + reason="" + if [[ $ref != refs/heads/master ]]; then + reason="scoped to $ref" + elif [[ $key == vcpkg/* ]]; then + # "/", cut the hash off the end to get the scope + scope="vcpkg/${key#vcpkg/}" + scope="${scope%/*}/" + if [[ $live != *" $scope "* ]]; then + reason="no matrix leg uses $scope" + fi + fi + if [[ -z $reason ]]; then + stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)" + if [[ $stamp -ne 0 && $stamp -lt $cutoff ]]; then + reason="unused since $accessed" + fi + fi + + if [[ -n $reason ]]; then + echo "::notice::deleting $key, $reason" + gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" + deleted=$((deleted + 1)) + fi + done <"$RUNNER_TEMP/caches.tsv" + + echo "::notice::deleted $deleted of $(wc -l <"$RUNNER_TEMP/caches.tsv") cache entries" diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml new file mode 100644 index 000000000..3cc7b2203 --- /dev/null +++ b/.github/workflows/vcpkg-cache-import.yml @@ -0,0 +1,121 @@ +name: Import vcpkg cache + +# After a runner image update changes every port's ABI hash, the first master run +# rebuilds protobuf, openssl and abseil from source and saves them into the shared +# cache, where every later run can restore them. +# +# Only master pushes are imported here: the collect step, keys.txt and the zip +# contents all come from the workflow run being imported, so importing from an +# unmerged pull request would let anyone who can push a branch — or a bot actor +# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into +# the scope that master and tag builds restore and link into signed releases. +# Forks build untrusted code and must never reach that scope either. +# +# repository_dispatch is not used on purpose: it would also resolve to the default branch, +# but it counts as a low-trust trigger and may only write there when it declares a +# write-capable cache-mode, which puts it in the same position as workflow_run. + +permissions: + actions: write # needed to save entries into the default branch's cache scope + contents: read + +on: + workflow_run: + workflows: ["Build Desktop"] + types: [completed] + +# Every import writes the same scopes, so let them queue instead of racing each other +concurrency: + group: vcpkg-cache-import + cancel-in-progress: false + +jobs: + list: + name: List new vcpkg entries + # Only a successful push to the default branch has archives worth importing. + # Only code that landed on master may populate the scope master runs restore. + # Bot actors are excluded on top: their runs must never promote binaries into + # the shared scope even if they somehow target the default branch. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'master' && + github.event.workflow_run.head_repository.full_name == github.repository && + !endsWith(github.event.workflow_run.actor.login, '[bot]') && + !endsWith(github.event.workflow_run.triggering_actor.login, '[bot]') + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + # workflow_run is a low-trust trigger, so cache access is read-only by default even + # though this run resolves to the default branch. Assert below that this took effect, + # because a save under read-only access only warns and would import nothing silently. + cache-mode: write + outputs: + entries: ${{ steps.scan.outputs.entries }} + + steps: + - name: "Download new vcpkg entries" + uses: actions/download-artifact@v8 + with: + pattern: vcpkg-new-* + path: incoming + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Assert write access" + shell: bash + run: | + if [[ $ACTIONS_CACHE_MODE != *write* ]]; then + echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" + exit 1 + fi + + - name: "List cache entries" + id: scan + shell: bash + working-directory: incoming + run: | + entries="$(find . -name keys.txt -type f -exec sh -c ' + for manifest; do + artifact="$(basename "$(dirname "$manifest")")" + while read -r key path; do + printf "{\"artifact\": \"%s\", \"key\": \"%s\", \"path\": \"%s\"}\n" \ + "$artifact" "$key" "$path" + done <"$manifest" + done + ' _ {} +)" + [[ -n $entries ]] || exit 0 + # Compact, because a multi line value cannot be written to GITHUB_OUTPUT + entries="$(jq -cs '.' <<<"$entries")" + echo "entries=$entries" >>"$GITHUB_OUTPUT" + echo "::notice::importing $(jq length <<<"$entries") vcpkg entries" + + save: + name: "Save ${{ matrix.entry.key }}" + needs: list + if: needs.list.outputs.entries + runs-on: ubuntu-slim + cache-mode: write + strategy: + # One job per archive, they run in parallel and each only uploads a single file + fail-fast: false + max-parallel: 20 + matrix: + entry: ${{ fromJSON(needs.list.outputs.entries) }} + + steps: + - name: "Download new vcpkg entries" + uses: actions/download-artifact@v8 + with: + pattern: ${{ matrix.entry.artifact }} + path: incoming + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Save vcpkg entry" + uses: actions/cache/save@v6 + with: + # The payload is a Windows or macOS archive while the upload runs on Linux, and + # without this the entry would be stamped as a Linux cache that neither of them + # is allowed to restore. + enableCrossOsArchive: true + key: ${{ matrix.entry.key }} + path: incoming/${{ matrix.entry.path }} \ No newline at end of file From eefe0bd672cfa8d0b982fbb6dbcf1316273d2f85 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:27:58 +0200 Subject: [PATCH 026/247] [CI] Collect an MSBuild performance summary and binary log on request (#7378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows job spends about 28 minutes inside one "Build project" group, with nothing in it that says where the time goes: MSBuild runs single-node because no --parallel is passed, so projects build one after another and it is not visible which projects, tasks or phases are responsible. Add a --profile flag that makes the Windows build write -flp:PerformanceSummary and -bl, repeat the summary in the job log so the numbers need no download, and report the core count, which decides how much parallelism is worth asking for. The binary log is uploaded as an artifact only on the runs that asked for it. The workflow drives it from a build_profile matrix key that is empty by default, so no build changes behaviour until someone sets it. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 41 +++++++++++++++++++++++++++-- .github/workflows/desktop-build.yml | 16 +++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 302972f30..eb850a88b 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -17,8 +17,9 @@ # --dir sets the name of the build dir, default is "build" # --cmake-generator sets CMAKE_GENERATOR as used by cmake # --target-macos-version sets the min os version - only used for macOS builds -# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION -# (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir ) +# --profile writes an MSBuild performance summary and binary log, Windows only +# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PROFILE +# (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir --profile) # exitcode: 1 for failure, 3 for invalid arguments # Read arguments @@ -115,6 +116,10 @@ while [[ $# != 0 ]]; do export CMAKE_GENERATOR=$1 shift ;; + '--profile') + BUILD_PROFILE=1 + shift + ;; '--target-macos-version') shift if [[ $# == 0 ]]; then @@ -209,6 +214,22 @@ function ccachestatsverbose() { fi } +function cpuinfo() { + # The number of cores decides how much build parallelism is useful; the hosted + # runners are not all sized alike, so read it off the machine instead of assuming. + case "$RUNNER_OS" in + Windows) + echo "cores: ${NUMBER_OF_PROCESSORS:-unknown}" + ;; + macOS) + echo "cores: $(sysctl -n hw.ncpu)" + ;; + *) + echo "cores: $(nproc 2>/dev/null || echo unknown)" + ;; + esac +} + # Compile if [[ $RUNNER_OS == macOS ]]; then # QTDIR is needed for macOS since we actually only use the cached thin Qt binaries instead of the install-qt-action, @@ -296,6 +317,13 @@ elif [[ $RUNNER_OS == Windows ]]; then # Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/ # and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true) + if [[ $BUILD_PROFILE ]]; then + # A performance summary says which projects and tasks cost what, the binary log + # keeps the full timeline; both are far too noisy to always produce. The files + # land next to the solution, i.e. in the build dir the workflow uploads. These + # join the MTT options above, which already carried the "--" separator. + buildflags+=("-flp:PerformanceSummary;v=q;LogFile=msbuild-perf.log" -bl:msbuild.binlog) + fi fi if [[ $USE_CCACHE ]]; then @@ -311,10 +339,19 @@ cmake .. "${flags[@]}" echo "::endgroup::" echo "::group::Build project" +cpuinfo echo "Running cmake --build with flags: ${buildflags[*]}" cmake --build . "${buildflags[@]}" echo "::endgroup::" +if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then + # Repeat the summary in the job log so the numbers are readable without + # downloading the binary log, which is hundreds of megabytes. + echo "::group::MSBuild performance summary" + cat msbuild-perf.log + echo "::endgroup::" +fi + if [[ $USE_CCACHE ]]; then if [[ $CCACHE_EVICTION_AGE ]]; then echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 1ce51afaa..7384ec8ad 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -385,6 +385,10 @@ jobs: target: 10 runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md + # Set build_profile: 1 to have the build write an MSBuild performance + # summary and a binary log; empty on every entry that runs by default. + build_profile: '' + cmake_generator: "Visual Studio 18 2026" cmake_generator_platform: x64 make_package: 1 @@ -400,6 +404,7 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: + BUILD_PROFILE: '${{ matrix.build_profile }}' CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy @@ -701,6 +706,17 @@ jobs: build/oracle/Release/*.pdb build/servatrice/Release/*.pdb + # Written only when the matrix entry sets build_profile, so the artifact + # exists exactly on the runs that were asked to produce one. + - name: "[Windows] Upload MSBuild binary log" + if: always() && matrix.os == 'Windows' && env.BUILD_PROFILE == '1' + uses: actions/upload-artifact@v7 + with: + if-no-files-found: error + name: ${{ steps.build.outputs.name }}-msbuild + path: build/msbuild.binlog + retention-days: 7 + - name: "Upload to release" if: needs.configure.outputs.tag != null && matrix.make_package == '1' id: upload_release From 0c10798ef42c5fbe0b371ecc12f49bfd84946d17 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:15:01 +0200 Subject: [PATCH 027/247] [CI] Build the Windows solution on more than one MSBuild node (#7379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmake --build only passes a job count to MSBuild when it is asked for one, and .ci/compile.sh never asked, so every Visual Studio generator build ran single-node: projects were built one after another and MTT only ever parallelized the files of whichever project happened to be building. Ask for one node per core, overridable with BUILD_PARALLEL_LEVEL, and fall back to a single node when the core count is not exposed. Alongside it set EnforceProcessCountAcrossBuilds and CL_MPCount, the knobs Microsoft's build throughput write-up pairs with MTT: without them the node count and MTT multiply into more cl.exe processes than the runner has cores for. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index eb850a88b..4aa189bc7 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -18,7 +18,7 @@ # --cmake-generator sets CMAKE_GENERATOR as used by cmake # --target-macos-version sets the min os version - only used for macOS builds # --profile writes an MSBuild performance summary and binary log, Windows only -# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PROFILE +# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE # (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir --profile) # exitcode: 1 for failure, 3 for invalid arguments @@ -314,9 +314,20 @@ if [[ $RUNNER_OS == macOS ]]; then fi elif [[ $RUNNER_OS == Windows ]]; then + # cmake only hands MSBuild a job count when one is asked for, and without it + # MSBuild builds a single node: one project at a time, whatever MTT does inside + # it. Ask for a node per core. + if [[ ! $BUILD_PARALLEL_LEVEL ]]; then + BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}" + fi + buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL") + # Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/ # and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt - buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true) + # EnforceProcessCountAcrossBuilds makes the CL_MPCount ceiling apply across all + # the projects the nodes are building, instead of each node running its own + # cl.exe processes and /m and MTT multiplying into an oversubscribed machine. + buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true -p:EnforceProcessCountAcrossBuilds=true -p:CL_MPCount="$BUILD_PARALLEL_LEVEL") if [[ $BUILD_PROFILE ]]; then # A performance summary says which projects and tasks cost what, the binary log # keeps the full timeline; both are far too noisy to always produce. The files From fea12a74e2df0dd18dd65a3d2178d2002f289d17 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:13:52 +0200 Subject: [PATCH 028/247] [CI] Build Windows releases with Ninja (#7380) The A/B run measured Ninja against the Visual Studio generator across ten builds: Ninja medians sit at ~17 min against ~21 for MSBuild, and more importantly Ninja already matches the Linux and macOS legs, runs the compiler itself and parallelizes the whole graph instead of one MSBuild node at a time. Single runs on shared 4-core hosts swing by minutes either way (VS and Ninja legs even ran in different Azure regions inside one matrix), so this switches on medians and engineering coherence, not on any one timing. The Ninja leg is now the release leg: it keeps the -Win10 suffix and the vcpkg scope (named after the vs2026 toolchain, whose cl.exe is unchanged, so the warm archives keep restoring), uploads PDBs from the single-config layout and ships installers to releases. The experiment scaffolding goes away with the generator it measured. --- .ci/compile.sh | 45 +++++++++++++++++------------ .github/workflows/desktop-build.yml | 43 ++++++++++++++------------- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 4aa189bc7..6789b9817 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -314,26 +314,33 @@ if [[ $RUNNER_OS == macOS ]]; then fi elif [[ $RUNNER_OS == Windows ]]; then - # cmake only hands MSBuild a job count when one is asked for, and without it - # MSBuild builds a single node: one project at a time, whatever MTT does inside - # it. Ask for a node per core. - if [[ ! $BUILD_PARALLEL_LEVEL ]]; then - BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}" - fi - buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL") + if [[ $CMAKE_GENERATOR == "Visual Studio"* ]]; then + # cmake only hands MSBuild a job count when one is asked for, and without it + # MSBuild builds a single node: one project at a time, whatever MTT does inside + # it. Ask for a node per core. + if [[ ! $BUILD_PARALLEL_LEVEL ]]; then + BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}" + fi + buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL") - # Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/ - # and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt - # EnforceProcessCountAcrossBuilds makes the CL_MPCount ceiling apply across all - # the projects the nodes are building, instead of each node running its own - # cl.exe processes and /m and MTT multiplying into an oversubscribed machine. - buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true -p:EnforceProcessCountAcrossBuilds=true -p:CL_MPCount="$BUILD_PARALLEL_LEVEL") - if [[ $BUILD_PROFILE ]]; then - # A performance summary says which projects and tasks cost what, the binary log - # keeps the full timeline; both are far too noisy to always produce. The files - # land next to the solution, i.e. in the build dir the workflow uploads. These - # join the MTT options above, which already carried the "--" separator. - buildflags+=("-flp:PerformanceSummary;v=q;LogFile=msbuild-perf.log" -bl:msbuild.binlog) + # Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/ + # and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt + # EnforceProcessCountAcrossBuilds makes the CL_MPCount ceiling apply across all + # the projects the nodes are building, instead of each node running its own + # cl.exe processes and /m and MTT multiplying into an oversubscribed machine. + buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true -p:EnforceProcessCountAcrossBuilds=true -p:CL_MPCount="$BUILD_PARALLEL_LEVEL") + if [[ $BUILD_PROFILE ]]; then + # A performance summary says which projects and tasks cost what, the binary log + # keeps the full timeline; both are far too noisy to always produce. The files + # land next to the solution, i.e. in the build dir the workflow uploads. These + # join the MTT options above, which already carried the "--" separator. + buildflags+=("-flp:PerformanceSummary;v=q;LogFile=msbuild-perf.log" -bl:msbuild.binlog) + fi + else + # Ninja runs the compiler itself, so it neither takes the MSBuild switches + # (it rejects -p: options outright) nor a job count: it sizes its own pool + # for the cores it finds. + echo "ninja $(ninja --version)" fi fi diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 7384ec8ad..beedaeaf2 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -385,17 +385,20 @@ jobs: target: 10 runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md - # Set build_profile: 1 to have the build write an MSBuild performance - # summary and a binary log; empty on every entry that runs by default. - build_profile: '' - - cmake_generator: "Visual Studio 18 2026" - cmake_generator_platform: x64 + # Ninja builds the whole dependency graph at once instead of one + # MSBuild node at a time, runs the compiler itself, and matches the + # Linux and macOS legs. Its median build phase measured ~17 min + # against ~21 for the Visual Studio generator (shared 4-core hosts + # swing either by minutes, so read medians, not single runs). + cmake_generator: Ninja make_package: 1 package_suffix: "-Win10" qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release + # Scope is named after the vs2026 toolchain, not the generator: the + # ABI hash covers cl.exe, which is identical, so this leg keeps + # restoring the warm archives. Renaming would orphan them. vcpkg_scope: 'vcpkg/windows10-x64-vs2026/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over @@ -404,7 +407,6 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: - BUILD_PROFILE: '${{ matrix.build_profile }}' CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy @@ -492,6 +494,13 @@ jobs: shell: bash run: choco install nsis + # Ninja invokes cl.exe directly and needs the MSVC environment on PATH. + - name: "[Windows] Set up MSVC environment" + if: matrix.os == 'Windows' + uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9 # v4.1.0 + with: + arch: x64 + - name: "Setup vcpkg cache" id: vcpkg-cache uses: TAServers/vcpkg-cache@v3 @@ -526,7 +535,6 @@ jobs: BUILDTYPE: '${{ matrix.type }}' CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }} CMAKE_GENERATOR: ${{ matrix.cmake_generator }} - CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }} DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} @@ -695,6 +703,8 @@ jobs: if-no-files-found: error path: ${{ steps.build.outputs.path }} + # Ninja is single-config: binaries and their PDBs land directly in the + # target directories instead of a Release subdirectory. - name: "[Windows] Upload PDBs (Program Databases)" if: matrix.os == 'Windows' && github.ref_type != 'tag' uses: actions/upload-artifact@v7 @@ -702,20 +712,9 @@ jobs: if-no-files-found: error name: ${{ steps.build.outputs.name }}-PDBs path: | - build/cockatrice/Release/*.pdb - build/oracle/Release/*.pdb - build/servatrice/Release/*.pdb - - # Written only when the matrix entry sets build_profile, so the artifact - # exists exactly on the runs that were asked to produce one. - - name: "[Windows] Upload MSBuild binary log" - if: always() && matrix.os == 'Windows' && env.BUILD_PROFILE == '1' - uses: actions/upload-artifact@v7 - with: - if-no-files-found: error - name: ${{ steps.build.outputs.name }}-msbuild - path: build/msbuild.binlog - retention-days: 7 + build/cockatrice/*.pdb + build/oracle/*.pdb + build/servatrice/*.pdb - name: "Upload to release" if: needs.configure.outputs.tag != null && matrix.make_package == '1' From 677692500b7cdacad6033ffce8b3d4c3e5dc2731 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:35:03 +0200 Subject: [PATCH 029/247] [Game] Raise rubber band above count label and collapse multi-selection on click (#7451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Game] Raise rubber band above count label and collapse multi-selection on click Raise rubber band on start and collapse multi-selection to clicked card when left-clicking an already-selected card with no modifiers. * [Game] Collapse multi-selection on release instead of press Collapsing at press time dropped the selection before the drag started, which broke dragging multiple cards. Keep the selection on press and collapse it on release when the pointer stayed within the drag distance. * [Game] Defer selection collapse until the double-click window passes A release without drag collapsed the multi-selection immediately, so a double-click that followed (playSelected acts on scene()->selectedItems()) only ever saw the single clicked card. When the card handles double-clicks on the selection, arm the collapse on a single-shot timer for QApplication::doubleClickInterval() instead and cancel it from the double-click handler; a new press disarms the other cards and a selection-count guard covers rubber-band/ctrl changes meanwhile. * [Game] Keep sideboard multi-selection on double-click DeckViewCard overrides doubleClickActsOnSelection to preserve the selection, restoring master behavior where double-clicking moves all selected cards between main and side. DeckView cancels any pending collapse before reading the selection. * [Game] Remove duplicated inline selection-collapse declarations * [Game] Fix DeckView collapse call visibility and multi-selection collapse Make cancelPendingSelectionCollapse() public so DeckView can call it on selected DeckViewCards. Always arm the deferred collapse on release instead of skipping cards whose double-click acts on the selection; the double-click-interval timer plus the cancel calls in CardItem::mouseDoubleClickEvent and DeckView::mouseDoubleClickEvent already preserve the selection for double-clicks. --------- Co-authored-by: Lukas Brübach --- .../board/abstract_card_item.cpp | 55 ++++++++++++++++++- .../game_graphics/board/abstract_card_item.h | 14 +++++ .../src/game_graphics/board/card_item.cpp | 8 +++ .../src/game_graphics/board/card_item.h | 1 + .../src/game_graphics/deckview/deck_view.cpp | 9 +++ .../src/game_graphics/deckview/deck_view.h | 4 ++ cockatrice/src/game_graphics/game_view.cpp | 1 + 7 files changed, 91 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index a79c5873e..cc121cfb2 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -12,6 +12,7 @@ #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -42,7 +44,7 @@ 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) + bgColor(Qt::transparent), isHovered(false), realZValue(0), pendingSelectionCollapse(false) { setCursor(Qt::OpenHandCursor); setFlag(ItemIsSelectable); @@ -344,6 +346,16 @@ void AbstractCardItem::setFaceDown(bool _facedown) void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { + cancelPendingSelectionCollapse(); + if (scene() != nullptr) { + // A new press starts a new gesture: no card may collapse the selection out from under it afterwards. + for (QGraphicsItem *selectedItem : scene()->selectedItems()) { + auto *selectedCard = qgraphicsitem_cast(selectedItem); + if (selectedCard != nullptr && selectedCard != this) { + selectedCard->cancelPendingSelectionCollapse(); + } + } + } if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) { emit cardShiftClicked(cardRef.name); } else if ((event->modifiers() & Qt::ControlModifier)) { @@ -351,6 +363,10 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) } else if (!isSelected()) { scene()->clearSelection(); setSelected(true); + } else if (event->button() == Qt::LeftButton && scene()->selectedItems().count() > 1) { + // Part of a multi-selection; collapse only if this turns out to be a click rather than a drag, + // so that dragging the whole selection keeps it intact. + pendingSelectionCollapse = true; } if (event->button() == Qt::LeftButton) { setCursor(Qt::ClosedHandCursor); @@ -362,6 +378,32 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { + if (pendingSelectionCollapse) { + pendingSelectionCollapse = false; + bool dragged = (event->screenPos() - event->buttonDownScreenPos(event->button())).manhattanLength() >= + 2 * QApplication::startDragDistance(); + if (!dragged) { + if (doubleClickActsOnSelection()) { + // A double-click may still arrive and must act on the whole multi-selection, so collapse only + // once the double-click window has passed without one. The generation guard (and the receiver + // context) drops the collapse if the item is gone, deselected, or a double-click cancelled it; + // the count guard drops it if the selection changed meanwhile (rubber band, ctrl-toggle). + const int generation = ++selectionCollapseGeneration; + const int armedSelectionCount = scene()->selectedItems().count(); + QTimer::singleShot(QApplication::doubleClickInterval(), this, [this, generation, armedSelectionCount] { + if (generation == selectionCollapseGeneration && scene() != nullptr && isSelected() && + scene()->selectedItems().count() == armedSelectionCount && armedSelectionCount > 1) { + scene()->clearSelection(); + setSelected(true); + } + }); + } else { + scene()->clearSelection(); + setSelected(true); + } + } + } + if (event->button() == Qt::MiddleButton) { emit deleteCardInfoPopup(cardRef.name); } @@ -370,6 +412,17 @@ void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) event->accept(); } +void AbstractCardItem::cancelPendingSelectionCollapse() +{ + pendingSelectionCollapse = false; + ++selectionCollapseGeneration; +} + +bool AbstractCardItem::doubleClickActsOnSelection() const +{ + return false; +} + void AbstractCardItem::processHoverEvent() { emit hovered(this); diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index 385f9c32f..e2b150ce8 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -41,6 +41,8 @@ protected: private: bool isHovered; qreal realZValue; + bool pendingSelectionCollapse; + int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings private slots: void pixmapUpdated(); @@ -130,6 +132,8 @@ public: } 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); @@ -144,6 +148,16 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override; void cacheBgColor(); + /** + * @brief Whether a double-click on this item acts on the whole selection. + * + * When true, a release without drag defers the multi-selection collapse + * until the double-click window passes, so a following double-click still + * sees every selected card. Subclasses that handle double-clicks on the + * selection must return true here and call + * cancelPendingSelectionCollapse() from their double-click handler. + */ + virtual bool doubleClickActsOnSelection() const; }; #endif diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index a04d86c15..1901a634f 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -518,6 +518,9 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { + // The preceding release deferred its multi-selection collapse for exactly this: the double-click plays + // every selected card, so the collapse must not run. + cancelPendingSelectionCollapse(); if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) && (SettingsCache::instance().userInterface().getDoubleClickToPlay())) { handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier)); @@ -525,6 +528,11 @@ void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) event->accept(); } +bool CardItem::doubleClickActsOnSelection() const +{ + return SettingsCache::instance().userInterface().getDoubleClickToPlay(); +} + bool CardItem::animationEvent() { int rotation = ROTATION_DEGREES_PER_FRAME; diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index df79ef378..0737d7f9c 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -175,6 +175,7 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; + bool doubleClickActsOnSelection() const override; }; #endif diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index c86bc9925..630530792 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -182,6 +182,15 @@ void DeckView::mouseDoubleClickEvent(QMouseEvent *event) QList result; QList sel = scene()->selectedItems(); + // The double-click goes to the view, not the card, so cancel any pending + // selection collapse on the cards before reading the selection. + for (auto *item : sel) { + if (auto *c = dynamic_cast(item)) { + c->cancelPendingSelectionCollapse(); + } + } + sel = scene()->selectedItems(); + for (int i = 0; i < sel.size(); i++) { auto *c = static_cast(sel.at(i)); auto *zone = static_cast(c->parentItem()); diff --git a/cockatrice/src/game_graphics/deckview/deck_view.h b/cockatrice/src/game_graphics/deckview/deck_view.h index 3b5b4b6f4..31cb0f87f 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.h +++ b/cockatrice/src/game_graphics/deckview/deck_view.h @@ -49,6 +49,10 @@ public: { return originZone; } + bool doubleClickActsOnSelection() const override + { + return true; + } protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index 1c92d74ae..cb59b3d7d 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -127,6 +127,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin) selectionOrigin = _selectionOrigin; previousBandRect = QRect(); rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0))); + rubberBand->raise(); rubberBand->show(); } From fec693ca2a8274054eb9fb1518dd878eb6cdc148 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:21:40 +0200 Subject: [PATCH 030/247] [Tests] Scope loader matcher settings to the process to fix parallel-run flakes (#7504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Tests] Scope loader matcher settings to the process to fix parallel-run flakes * [Tests] Clean up per-process settings sandbox after loader matcher run * [Tests] Skip loader matcher sandbox cleanup during gtest discovery --------- Co-authored-by: Lukas Brübach --- tests/loader_local_matching_test.cpp | 38 +++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp index 13d3e5bb3..7632e60d4 100644 --- a/tests/loader_local_matching_test.cpp +++ b/tests/loader_local_matching_test.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -223,8 +224,43 @@ int main(int argc, char **argv) } QCoreApplication app(argc, argv); + + // Every TEST() runs as its own process and CI runs them in parallel (ctest -j4), + // while the picture paths round-trip through the process-global SettingsCache + // file. Parallel cases must therefore never share one settings directory: on + // platforms where QStandardPaths ignores the $HOME override above (macOS + // resolves the home directory via system APIs), every case would otherwise read + // and write the same paths.ini, and a sibling's setPicsPath can land between our + // own set and the loader's get — so the loader searches a foreign (or empty) + // directory and flakily returns a null image. Scope the settings to this process + // with a pid-unique application name; PIDs are never reused by live processes. + QCoreApplication::setOrganizationName(QStringLiteral("CockatriceTest")); + QCoreApplication::setApplicationName( + QStringLiteral("loader-local-matching-test-%1").arg(QCoreApplication::applicationPid())); + QLoggingCategory::setFilterRules("card_picture_loader.*=false\nsettings_cache.*=false"); ::testing::InitGoogleTest(&argc, argv); + + // CMake's gtest discovery runs this binary with --gtest_list_tests, which lists + // without running any test body. Arming the cleanup guard here would make it the + // sole SettingsCache::instance() call in that mode, instantiating the + // Q_GLOBAL_STATIC singleton (and its QNetworkAccessManagers) just to tear down at + // exit — which LeakSanitizer reports as leaks and fails discovery on the sanitizer + // leg. Skip the sandbox cleanup entirely when only listing. + if (::testing::GTEST_FLAG(list_tests)) { + return RUN_ALL_TESTS(); + } + + // Best-effort cleanup of this process's settings sandbox. QStandardPaths test + // mode is never auto-cleaned, and on macOS the $HOME override above is ignored, + // so without this every run litters ~/.qttest with one pid-unique directory. + // Safe under parallel runs: the pid-unique application name above guarantees + // this directory belongs to this process alone. + const auto cleanupSandbox = qScopeGuard([] { + QDir(SettingsCache::instance().getDataPath()).removeRecursively(); + QDir(SettingsCache::instance().getCachePath()).removeRecursively(); + }); + return RUN_ALL_TESTS(); -} \ No newline at end of file +} From bbf0425655b3ed4b58effb024a87c9d0569130f4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:41:01 +0200 Subject: [PATCH 031/247] [Style] Confirm broken Windows styles with a dialog offering Fusion (#7507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Style] Confirm broken Windows styles with a dialog offering Fusion * [Style] Address review nits on broken style dialog Extract Fusion index lookup into findFusionIndex helper and add all message box buttons before choosing the default button. --------- Co-authored-by: Lukas Brübach --- .../appearance_settings_page.cpp | 98 +++++++++++++++++-- .../settings_page/appearance_settings_page.h | 2 + 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index 4c38459c6..be2d5735e 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -84,10 +85,9 @@ AppearanceSettingsPage::AppearanceSettingsPage() const QString currentStyle = cfg.styleName; const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0); + previousStyle = styleCombo.currentData().toString(); - connect(&styleCombo, &QComboBox::currentIndexChanged, this, - [this] { themeManager->setStyleName(styleCombo.currentData().toString()); }); - connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::updateStyleWarning); + connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::styleComboChanged); connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] { const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); @@ -104,6 +104,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0); styleCombo.blockSignals(false); + previousStyle = styleCombo.currentData().toString(); updateStyleWarning(); }); @@ -467,12 +468,88 @@ void AppearanceSettingsPage::updateHomeTabSettingsVisibility() homeTabDisplayCardNameCheckBox.setVisible(visible); } +static bool isBrokenStyle(const QString &style) +{ + return style.compare("windows11", Qt::CaseInsensitive) == 0 || + style.compare("windowsvista", Qt::CaseInsensitive) == 0; +} + +static int findFusionIndex(const QComboBox &combo) +{ + int fusionIdx = combo.findData(QStringLiteral("Fusion")); + if (fusionIdx >= 0) { + return fusionIdx; + } + for (int i = 0; i < combo.count(); ++i) { + if (combo.itemData(i).toString().compare(QStringLiteral("Fusion"), Qt::CaseInsensitive) == 0) { + return i; + } + } + return -1; +} + void AppearanceSettingsPage::updateStyleWarning() { - const QString style = styleCombo.currentData().toString(); - const bool broken = - style.compare("windows11", Qt::CaseInsensitive) == 0 || style.compare("windowsvista", Qt::CaseInsensitive) == 0; - styleWarningLabel.setVisible(broken); + styleWarningLabel.setVisible(isBrokenStyle(styleCombo.currentData().toString())); +} + +void AppearanceSettingsPage::styleComboChanged(int index) +{ + if (index < 0) { + return; + } + + const QString newStyle = styleCombo.itemData(index).toString(); + if (!isBrokenStyle(newStyle)) { + themeManager->setStyleName(newStyle); + previousStyle = newStyle; + updateStyleWarning(); + return; + } + + QMessageBox msgBox(this); + msgBox.setWindowTitle(tr("Broken style")); + msgBox.setIcon(QMessageBox::Warning); + msgBox.setText(tr("The \"%1\" style is broken and can break the game view: dragging cards across zones can " + "shrink the board to a tiny grey window that can only be fixed by rejoining the game.") + .arg(newStyle)); + msgBox.setInformativeText( + tr("It can also render controls such as combo boxes unreadable, especially in dark mode.\n\n" + "Fusion is the recommended style on every platform. Switch to Fusion instead?")); + + int fusionIdx = findFusionIndex(styleCombo); + QPushButton *fusionButton = nullptr; + if (fusionIdx >= 0) { + fusionButton = msgBox.addButton(tr("Use Fusion instead"), QMessageBox::AcceptRole); + } + QPushButton *keepButton = msgBox.addButton(tr("Use %1 anyway").arg(newStyle), QMessageBox::DestructiveRole); + msgBox.addButton(QMessageBox::Cancel); + if (fusionButton != nullptr) { + msgBox.setDefaultButton(fusionButton); + } else { + msgBox.setDefaultButton(keepButton); + } + + msgBox.exec(); + + const QAbstractButton *clicked = msgBox.clickedButton(); + if (fusionButton != nullptr && clicked == fusionButton) { + styleCombo.setCurrentIndex(fusionIdx); + return; + } + if (clicked == keepButton) { + themeManager->setStyleName(newStyle); + previousStyle = newStyle; + updateStyleWarning(); + return; + } + + // Cancel: revert to the previously applied style without re-prompting + styleCombo.blockSignals(true); + const int previousIdx = styleCombo.findData(previousStyle); + styleCombo.setCurrentIndex(previousIdx >= 0 ? previousIdx : 0); + styleCombo.blockSignals(false); + updateStyleWarning(); } void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value) @@ -538,9 +615,10 @@ void AppearanceSettingsPage::retranslateUi() styleComboLabel.setText(tr("Active theme style:")); styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)")); styleWarningLabel.setText( - tr("Warning: the Windows 11 and Windows Vista styles are buggy and can render controls such as combo boxes " - "unreadable, especially in dark mode. Fusion is the recommended style on every platform: it is Qt's " - "cross-platform style and the one Cockatrice is developed against.")); + tr("Warning: the Windows 11 and Windows Vista styles are broken and can break the game view (dragging " + "cards can shrink the board to a tiny grey window that requires rejoining to fix), and can render " + "controls such as combo boxes unreadable, especially in dark mode. Fusion is the recommended style on " + "every platform: it is Qt's cross-platform style and the one Cockatrice is developed against.")); updateStyleWarning(); editPaletteButton.setText(tr("Edit theme palette")); diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 8afd326a3..f919a2a2f 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -23,6 +23,7 @@ private slots: void editPalette(); void updateHomeTabSettingsVisibility(); void updateStyleWarning(); + void styleComboChanged(int index); void showShortcutsChanged(QT_STATE_CHANGED_T enabled); void overrideAllCardArtWithPersonalPreferenceToggled(QT_STATE_CHANGED_T enabled); @@ -40,6 +41,7 @@ private: QComboBox styleCombo; QLabel styleWarningLabel; QPushButton editPaletteButton; + QString previousStyle; QLabel homeTabBackgroundSourceLabel; QComboBox homeTabBackgroundSourceBox; From 96b0888a7365744ee26e845fe0d389a98132d0b4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:21:07 +0200 Subject: [PATCH 032/247] [Oracle] Don't report a cancelled card database update as successful (#7082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Oracle] Don't report a cancelled card database update as successful (#3470) Took 3 minutes * [Oracle] Only report cancel when nothing was saved yet Rejecting the wizard after SaveSetsPage or a download page already wrote the database to disk still replaced cards.xml on disk, but wasCancelled() forced exit code 1 so Cockatrice skipped the reload. Track successful saves with OracleWizard::markDatabaseSaved() and only treat a cancel as a failure when nothing was saved. * [Oracle] Restore updater stderr in card update error dialog --------- Co-authored-by: Lukas Brübach --- cockatrice/src/interface/window_main.cpp | 20 ++++++++++++-------- cockatrice/src/interface/window_main.h | 2 +- oracle/src/main.cpp | 5 ++++- oracle/src/oraclewizard.cpp | 8 ++++++++ oracle/src/oraclewizard.h | 15 +++++++++++++++ oracle/src/pages.cpp | 5 +++++ oracle/src/pagetemplates.cpp | 5 +++++ 7 files changed, 50 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 815cec1d2..57d6e8b15 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -1253,7 +1253,7 @@ void MainWindow::createCardUpdateProcess(bool background) << dir.absoluteFilePath(binaryName); QMessageBox::warning(this, tr("Error"), tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName)); - exitCardDatabaseUpdate(); + exitCardDatabaseUpdate(false); emit cardDatabaseUpdateFinished(false); return; } @@ -1330,7 +1330,7 @@ void MainWindow::cardUpdateStandardError() } } -void MainWindow::exitCardDatabaseUpdate() +void MainWindow::exitCardDatabaseUpdate(bool doReload) { if (!cardUpdateProcess) { return; @@ -1339,9 +1339,11 @@ void MainWindow::exitCardDatabaseUpdate() cardUpdateProcess = nullptr; statusBar()->clearMessage(); - // This can be the first successful load of the session (e.g. the onboarding - // download), so it must own the set check for that load. - reloadCardDatabaseAndCheckSets(); + if (doReload) { + // This can be the first successful load of the session (e.g. the onboarding + // download), so it must own the set check for that load. + reloadCardDatabaseAndCheckSets(); + } } void MainWindow::reloadCardDatabaseAndCheckSets() @@ -1387,7 +1389,7 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) } qCWarning(CardDatabaseUpdateLog) << "The card database updater exited with an error:" << error; - exitCardDatabaseUpdate(); + exitCardDatabaseUpdate(false); QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error)); emit cardDatabaseUpdateFinished(false); } @@ -1400,12 +1402,14 @@ void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatu cardUpdateProgressOutput(); // drain any progress lines not yet parsed const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); - if (exitStatus == QProcess::NormalExit) { + if (success) { SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); + } else if (exitStatus == QProcess::NormalExit) { + statusBar()->showMessage(tr("Card database update cancelled."), 10000); } qCInfo(CardDatabaseUpdateLog) << "The card database updater finished with exit code" << exitCode << "exit status" << static_cast(exitStatus) << "success:" << success; - exitCardDatabaseUpdate(); + exitCardDatabaseUpdate(success); notifyCardUpdateFinishedFromTray(success); emit cardDatabaseUpdateFinished(success); } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index ee8873000..2b6ed5633 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -157,7 +157,7 @@ private: return "oracle"; } void createCardUpdateProcess(bool background = false); - void exitCardDatabaseUpdate(); + void exitCardDatabaseUpdate(bool doReload); /** * @brief Reloads the card database on a worker thread and runs checkUnknownSets() once the finished * snapshot has been swapped in. diff --git a/oracle/src/main.cpp b/oracle/src/main.cpp index cae2ee37d..bf56df5c9 100644 --- a/oracle/src/main.cpp +++ b/oracle/src/main.cpp @@ -85,5 +85,8 @@ int main(int argc, char *argv[]) QTimer::singleShot(0, &wizard, [&wizard]() { wizard.runInBackground(); }); } - return app.exec(); + const int execResult = app.exec(); + // A cancel only counts when nothing was written yet: closing the wizard + // after the card database was already saved on disk is still an update. + return (wizard.wasCancelled() && !wizard.hasSavedDatabase()) ? 1 : execResult; } diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index 05f08ce64..d2b3a71a4 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -128,12 +128,20 @@ void OracleWizard::accept() void OracleWizard::reject() { + cancelled = true; + // The wizard is being closed while a page may still run a worker on the // importer. Ask it to stop before the wizard (and the importer child) is // destroyed, so the worker thread never touches freed memory. if (auto *active = dynamic_cast(currentPage())) { active->cancelWork(); } + + const auto replies = nam->findChildren(); + for (QNetworkReply *reply : replies) { + reply->abort(); + } + QWizard::reject(); } diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index ea652bdc5..b1dc5eab2 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -20,6 +20,19 @@ public: explicit OracleWizard(QWidget *parent = nullptr); void accept() override; void reject() override; + bool wasCancelled() + { + return cancelled; + } + /** @brief Records that the card database was written to disk, so a later cancel still counts as an update. */ + void markDatabaseSaved() + { + databaseSaved = true; + } + bool hasSavedDatabase() + { + return databaseSaved; + } void enableButtons(); void disableButtons(); void retranslateUi(); @@ -67,6 +80,8 @@ private: QByteArray tokensData; QString cardSourceUrl; QString cardSourceVersion; + bool cancelled = false; + bool databaseSaved = false; void migrateOracleSettings(); /** diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 8c286fb46..ed8e04d1a 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -484,6 +484,10 @@ void LoadSetsPage::actDownloadFinishedSetsFile() { // check for a reply auto *reply = dynamic_cast(sender()); + if (wizard()->wasCancelled()) { + reply->deleteLater(); + return; + } auto errorCode = reply->error(); if (errorCode != QNetworkReply::NoError) { QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString())); @@ -895,6 +899,7 @@ bool SaveSetsPage::validatePage() return false; } + wizard()->markDatabaseSaved(); return true; } diff --git a/oracle/src/pagetemplates.cpp b/oracle/src/pagetemplates.cpp index d37818dcb..dcc8e070c 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -178,6 +178,10 @@ void SimpleDownloadFilePage::actDownloadFinished() { // check for a reply auto *reply = dynamic_cast(sender()); + if (wizard()->wasCancelled()) { + reply->deleteLater(); + return; + } QNetworkReply::NetworkError errorCode = reply->error(); if (errorCode != QNetworkReply::NoError) { QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString())); @@ -242,6 +246,7 @@ bool SimpleDownloadFilePage::saveToFile() // clean saved downloadData downloadData = QByteArray(); + wizard()->markDatabaseSaved(); return true; } From 213ce691ddb16e883e58c57760f0212b79146435 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:10:07 +0200 Subject: [PATCH 033/247] [Completer] Minimum size popup, fall back to compact design if not possible. (#7111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Completer] Minimum size popup, fall back to compact design if not possible. Took 15 minutes Took 6 seconds Took 5 minutes * [Completer] Fix popup going offscreen by clamping in screen coordinates The position clamping in applyPopupWidth() was comparing popup->pos() (parent coordinates) against screen->availableGeometry() (screen coordinates). When the parent widget is not at the screen origin the clamping computed a position in the wrong coordinate space, placing the popup offscreen. Convert to global coordinates first, clamp, then map back to parent coordinates. * [Completer] Clamp popup from geometry() in screen coordinates setMinimumWidth() already widens the popup synchronously, so the old width() < minWidth guard never ran and the popup grew past the right screen edge. The popup is also a top-level Qt::Popup window, so move() takes global coordinates and the mapFromGlobal round-trip moved it to the top-left of the screen. Clamp x from geometry() after setMinimumWidth() and move(QPoint(clampedX, popup->y())) directly. --------- Co-authored-by: Lukas Brübach --- .../utility/card_completer_delegate.cpp | 58 ++++++++++++++----- .../widgets/utility/card_completer_delegate.h | 6 +- .../widgets/utility/card_completer_styler.cpp | 38 ++++++++++++ .../widgets/utility/card_completer_styler.h | 1 + 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index 8042fb9fc..af07590a8 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -46,6 +46,22 @@ template class QSharedPointer; namespace { +// Below this row width the delegate switches to a compact layout so the card +// name stays readable when the popup cannot be as wide as PopupRowWidth. +constexpr int CompactRowWidth = 380; +constexpr int CompactSymbolRadius = 6; +constexpr int CompactSymbolSpacing = 1; +constexpr int CompactRightPad = 8; +constexpr int ManaZoneWidth = 110; +constexpr int CompactManaZoneWidth = 70; +constexpr int RightPad = 14; +constexpr int TextLeftPad = 12; +constexpr int TextRightGap = 8; + +// Below this text width the type-line band is dropped so the name can use the +// whole row height instead of being elided to a single readable character. +constexpr int MinNameWidth = 120; + struct ManaColor { QColor fill; @@ -125,7 +141,7 @@ QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const } // Fixed wide rows so the popup has room for name, type line, set and mana - return {480, CardRowHeight}; + return {PopupRowWidth, CardRowHeight}; } // --------------------------------------------------------------------------- @@ -176,7 +192,12 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt // --------------------------------------------------------------------------- -int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const +int CardCompleterDelegate::drawManaCost(QPainter *p, + const QRect &row, + const QString &manaCost, + int radius, + int spacing, + int rightPad) const { if (manaCost.isEmpty()) { return row.right(); @@ -205,11 +226,9 @@ int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QSt totalW += PartGap; } - totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * SymbolSpacing; + totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * spacing; } - const int rightPad = 14; - int x = row.right() - rightPad - totalW + radius; const int cy = row.center().y(); @@ -219,11 +238,11 @@ int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QSt for (const QString &sym : symbols) { drawManaSymbol(p, {x, cy}, sym, radius); - x += diam + SymbolSpacing; + x += diam + spacing; } if (i < parts.size() - 1) { - x += PartGap - SymbolSpacing; + x += PartGap - spacing; } } @@ -290,6 +309,14 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem const QString typeLine = card->getCardType(); const QString setCode = setCodeForCard(card); + // When the row is too narrow for the full layout, use smaller mana pips and + // a slimmer mana zone so the card name keeps as much room as possible. + const bool compact = rect.width() < CompactRowWidth; + const int symbolRadius = compact ? CompactSymbolRadius : SymbolRadius; + const int symbolSpacing = compact ? CompactSymbolSpacing : SymbolSpacing; + const int rightPad = compact ? CompactRightPad : RightPad; + const int manaZoneWidth = compact ? CompactManaZoneWidth : ManaZoneWidth; + const QColor accent = accentForColors(card->getColors()); const QColor base = pal.color(QPalette::Base); @@ -338,7 +365,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem // Right mana zone // ----------------------------------------------------------------------- - const QRectF manaZone(cardRect.right() - 110, cardRect.top(), 110, cardRect.height()); + const QRectF manaZone(cardRect.right() - manaZoneWidth, cardRect.top(), manaZoneWidth, cardRect.height()); QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft()); @@ -380,22 +407,27 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem // Mana cost // ----------------------------------------------------------------------- - const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, SymbolRadius); + const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, symbolRadius, symbolSpacing, rightPad); // ----------------------------------------------------------------------- // Card name + type line + set code // ----------------------------------------------------------------------- - const int textLeft = cardRect.left() + AccentBarWidth + 12; - const int textRight = costLeft - 8; + const int textLeft = cardRect.left() + AccentBarWidth + TextLeftPad; + const int textRight = costLeft - TextRightGap; const int textWidth = qMax(0, textRight - textLeft); + // If even the compact layout leaves too little room for a readable name, + // drop the type-line band and let the name use the full row height. + const bool showInfoBand = textWidth >= MinNameWidth; + // ----------------------------------------------------------------------- // Card name (top band) // ----------------------------------------------------------------------- { - const QRect nameRect(textLeft, rect.top() + 2, textWidth, 20); + const QRect nameRect = showInfoBand ? QRect(textLeft, rect.top() + 2, textWidth, 20) + : QRect(textLeft, rect.top() + 2, textWidth, rect.height() - 4); QFont f = option.font; f.setPixelSize(13); @@ -416,7 +448,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem // Type line + set code (bottom band) // ----------------------------------------------------------------------- - { + if (showInfoBand) { const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24); QFont f = option.font; diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h index ad81d86e4..cef25b783 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h @@ -36,6 +36,9 @@ public: void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + // Content width of one popup row; the completer popup is sized to fit it. + static constexpr int PopupRowWidth = 480; + private: // Set short codes, resolved once per card name and cached mutable QCache setCodeCache; @@ -48,7 +51,8 @@ private: // Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and // adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used - int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const; + int + drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius, int spacing, int rightPad) const; // Resolve the preferred printing's set short code for a card QString setCodeForCard(const QSharedPointer &card) const; diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 1ddcc4200..8aa32556f 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -103,6 +104,7 @@ bool CardCompleterStyler::eventFilter(QObject *obj, QEvent *ev) case QEvent::Show: case QEvent::Move: case QEvent::Resize: + applyPopupWidth(); updateOrientation(); reposition(); break; @@ -159,6 +161,42 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event) // --------------------------------------------------------------------------- +void CardCompleterStyler::applyPopupWidth() +{ + auto *popup = completer->popup(); + + // QCompleter sizes its popup to the line edit, so a narrow search field + // would crush the rows. Ask for the width the rows are designed for (plus + // the frame and scrollbar) and let QWidget's geometry clamping keep the + // popup that wide, clamped to the screen it is shown on. + int contentWidth = CardCompleterDelegate::PopupRowWidth + popup->frameWidth() * 2; + if (auto *vbar = popup->verticalScrollBar(); vbar != nullptr) { + contentWidth += vbar->sizeHint().width(); + } + + const QScreen *screen = popup->screen(); + const QRect available = screen ? screen->availableGeometry() : QRect(); + + const int minWidth = available.isEmpty() ? contentWidth : qMin(contentWidth, available.width()); + popup->setMinimumWidth(minWidth); + + // setMinimumWidth() already widened the popup if it was narrower, but + // QCompleter positioned it for the line-edit width, so the widened popup + // can now extend past the right screen edge. The popup is a top-level + // Qt::Popup window, so geometry()/move() are already in screen + // coordinates: clamp the x from geometry() and move directly. + if (!available.isEmpty()) { + const QRect geom = popup->geometry(); + const int clampedX = + qBound(available.left(), geom.x(), qMax(available.left(), available.right() - geom.width() + 1)); + if (clampedX != geom.x()) { + popup->move(QPoint(clampedX, popup->y())); + } + } +} + +// --------------------------------------------------------------------------- + void CardCompleterStyler::updateOrientation() { above = isPopupAboveWidget(); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.h b/cockatrice/src/interface/widgets/utility/card_completer_styler.h index 9245c9095..2912f7312 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.h @@ -52,6 +52,7 @@ private: void stopPreviewFade(); void reposition(); + void applyPopupWidth(); void updateOrientation(); void ensureClosestSelected(); From 456d9d9e708b697c7e7e00c037ab7843fe18fff7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:47:43 +0200 Subject: [PATCH 034/247] [Server] Inform client of login/registration rate limit timeout (#7468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security] Add per-address rate limiting for auth endpoints Introduce a thread-safe RateLimiter that tracks attempts per key (IP address) within a sliding time window, and wire it into the authentication endpoints: - Login: failed login attempts from an address are counted; once the configured maximum is exceeded within the window, further logins from that address are rejected with RespTooManyRequests. A successful login clears the failed attempts for that address. - Registration: implement the previously stubbed tooManyRegistrationAttempts, limiting how many accounts can be created per address per window. - Forgot-password: throttle both the email-request and the email-challenge paths per address. New [security] settings with defaults: max_login_attempts_per_ip=5 / login_attempt_window_seconds=900 max_registrations_per_ip=2 / registration_window_seconds=3600 max_forgot_password_requests_per_ip=3 / forgot_password_window_seconds=3600 Adds unit tests for the RateLimiter (window limit, over-limit blocking, clearing, per-key independence). Took 3 minutes * [Server] Fix rate limiter: disable semantics, draining window, bounded keys, pre-work login gate - recordAttempt()/isBlocked() return false when maxAttempts <= 0 or windowSeconds <= 0, so the documented 'set to 0 to disable' actually disables instead of blocking every address permanently - Keys at their limit are rejected without recording, so repeated attempts at the attacker's pace cannot keep sliding the window and holding the lockout open; a block now clears once the recorded attempts age out of windowSeconds - Attempts map is bounded: an opportunistic prune (every 60s) erases keys whose attempts have fully aged out - Login is throttled before the work: isLoginRateLimited() (a new virtual on Server, backed by isBlocked()) gates loginUser(), so a locked-out address no longer burns a database round trip and password verification per attempt - recordAttemptAt()/isBlockedAt() time seams make the sliding-window behaviour deterministically testable; tests rewritten for the real semantics (no reliance on the maxAttempts=0 bug, no sleeps) * [Server] Inform client of login/registration rate limit timeout - Add retryAfterSeconds/retryAfterSecondsAt to RateLimiter to compute seconds until oldest attempt exits the sliding window - Expose loginRateLimitRemainingSeconds via Server/Servatrice - Include denied_end_time (absolute epoch) in RespTooManyRequests for login pre-auth and when transitioning to lockout after failed attempt - Include denied_end_time in registration rate limit response - Client: show retry time for login RespTooManyRequests - Client: simplify registration RespTooManyRequests message to show retry time when provided - Add test for retryAfterSecondsAt * [Client] Restore registration guidance and drop duplicated rate limit reason The registration RespTooManyRequests case now only shows the retry-after copy when the server actually sent a denied_end_time; the per-email account cap path (no end time) falls back to the previous guidance about already having an account and contacting the server operator. Drop the canned denied_reason_str from the new login rate limit branches so the client no longer prints the same sentence twice. * [Server] Exempt trusted_sources from auth rate limits Addresses in security/trusted_sources bypass per-address limits, matching the existing max_users_per_address bypass in initSession. Without this, Webatrice behind local nginx (all clients as 127.0.0.1) would lock out every web user after 5 mistyped passwords, and cap the whole server at 2 registrations / 3 forgot-password requests per window. Adds Servatrice::isRateLimitExempt helper; recordFailedLogin, isLoginRateLimited, recordRegistrationAttempt and recordForgotPasswordAttempt all check it. # Conflicts: # servatrice/src/servatrice.cpp * [Server] Key login rate limit by (address, username) A successful login cleared the whole per-IP failure history, so 4 bad guesses plus 1 self-login (or guest UnknownUser login) from the same address bypassed the limiter. Failures are now bucketed per (address, lowercase username) and a success clears only its own pair. --------- Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 26 +++- .../network/server/remote/server.h | 33 +++++ .../server/remote/server_protocolhandler.cpp | 23 ++++ servatrice/CMakeLists.txt | 1 + servatrice/servatrice.ini.example | 21 +++ servatrice/src/ratelimiter.cpp | 125 ++++++++++++++++++ servatrice/src/ratelimiter.h | 93 +++++++++++++ servatrice/src/servatrice.cpp | 100 ++++++++++++++ servatrice/src/servatrice.h | 22 +++ servatrice/src/serversocketinterface.cpp | 30 ++++- tests/CMakeLists.txt | 11 ++ tests/rate_limiter_test.cpp | 124 +++++++++++++++++ 12 files changed, 600 insertions(+), 9 deletions(-) create mode 100644 servatrice/src/ratelimiter.cpp create mode 100644 servatrice/src/ratelimiter.h create mode 100644 tests/rate_limiter_test.cpp 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 7480da27e..9b985073d 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -239,6 +239,17 @@ void ConnectionController::onLoginError(int r, break; } + case Response::RespTooManyRequests: { + QString bannedStr = endTime ? tr("Too many login attempts. Try again after %1.") + .arg(QDateTime::fromSecsSinceEpoch(endTime).toString()) + : tr("Too many login attempts. Please try again later."); + if (!reasonStr.isEmpty()) { + bannedStr.append("\n\n" + reasonStr); + } + QMessageBox::critical(dialogParent, tr("Error"), bannedStr); + break; + } + case Response::RespWrongPassword: { QMessageBox::critical(dialogParent, tr("Error"), tr("Incorrect username or password. " @@ -365,12 +376,15 @@ void ConnectionController::onRegisterError(int r, QString reasonStr, quint32 end break; } case Response::RespTooManyRequests: { - QMessageBox::critical(dialogParent, tr("Registration denied"), - tr("It appears you are attempting to register a new account on this server yet you " - "already have an account registered with the email provided. This server " - "restricts the number of accounts a user can register per address. Please " - "contact the server operator for further assistance or to obtain your " - "credential information.")); + QString deniedStr = + endTime ? tr("Too many registration attempts. Try again after %1.") + .arg(QDateTime::fromSecsSinceEpoch(endTime).toString()) + : tr("It appears you are attempting to register a new account on this server yet you " + "already have an account registered with the email provided. This server " + "restricts the number of accounts a user can register per address. Please " + "contact the server operator for further assistance or to obtain your " + "credential information."); + QMessageBox::critical(dialogParent, tr("Registration denied"), deniedStr); break; } case Response::RespPasswordTooShort: { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 99247bb67..932a136ff 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -187,6 +187,39 @@ public: virtual void observeGameStartDurationMs(qint64 /* elapsedMs */) { } + /** + * @brief Record a failed login attempt for the (address, username) pair; returns true if now locked out. + * + * Keyed by pair so a successful login as one user cannot wipe out another + * user's failure history from the same address (e.g. a guest login or the + * attacker's own account clearing the way for further guessing). + */ + virtual bool recordFailedLogin(const QString & /*ipAddress*/, const QString & /*userName*/) + { + return false; + } + /** + * @brief True if the (address, username) pair is already locked out of logging in. + * + * Consulted before any authentication work (database round trip, password + * verification) so a blocked pair cannot burn server CPU per attempt. + */ + virtual bool isLoginRateLimited(const QString & /*ipAddress*/, const QString & /*userName*/) + { + return false; + } + /** + * @brief Seconds remaining before the login rate limit for the (address, username) pair lifts. + * Returns 0 if not limited. + */ + virtual int loginRateLimitRemainingSeconds(const QString & /*ipAddress*/, const QString & /*userName*/) + { + return 0; + } + /** @brief Clear any failed-login lockout for the (address, username) pair, e.g. after a successful login. */ + virtual void clearFailedLogins(const QString & /*ipAddress*/, const QString & /*userName*/) + { + } Server_DatabaseInterface *getDatabaseInterface() const; int getNextLocalGameId() diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index fc5cfa148..fbca9589a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -592,6 +592,17 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd QString reasonStr; int banSecondsLeft = 0; QString connectionType = getConnectionType(); + // Throttle before doing any work: a locked-out address must not get a full + // database round trip and password verification on every attempt. + if (server->isLoginRateLimited(getAddress(), userName)) { + int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName); + if (remaining > 0) { + auto *re = new Response_Login; + re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch()); + rc.setResponseExtension(re); + } + return Response::RespTooManyRequests; + } AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft, clientId, clientVersion, connectionType); switch (res) { @@ -605,10 +616,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd return Response::RespUserIsBanned; } case NotLoggedIn: + if (server->recordFailedLogin(getAddress(), userName)) { + int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName); + if (remaining > 0) { + auto *re = new Response_Login; + re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch()); + rc.setResponseExtension(re); + } + return Response::RespTooManyRequests; + } return Response::RespWrongPassword; case WouldOverwriteOldSession: return Response::RespWouldOverwriteOldSession; case UsernameInvalid: { + server->recordFailedLogin(getAddress(), userName); auto *re = new Response_Login; re->set_denied_reason_str(reasonStr.toStdString()); rc.setResponseExtension(re); @@ -619,10 +640,12 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd case ClientIdRequired: return Response::RespClientIdRequired; case UserIsInactive: + server->recordFailedLogin(getAddress(), userName); return Response::RespAccountNotActivated; case PasswordChangeRequired: return Response::RespPasswordChangeRequired; default: + server->clearFailedLogins(getAddress(), userName); authState = res; usingRealPassword = needsHash; } diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index b45608d86..f6d28d6c1 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -9,6 +9,7 @@ set(servatrice_SOURCES src/event_loop_watchdog.cpp src/main.cpp src/metrics_registry.cpp + src/ratelimiter.cpp src/servatrice.cpp src/servatrice_connection_pool.cpp src/servatrice_database_interface.cpp diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 3ee52dd21..f802f0244 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -366,6 +366,27 @@ max_users_websocket=500 ; Maximum number of users that can connect from the same IP address; useful to avoid bots, default is 4 max_users_per_address=4 +; Maximum number of failed login attempts from a single IP address before that +; address is temporarily locked out. Default is 5; set to 0 to disable. +max_login_attempts_per_ip=5 + +; Length in seconds of the sliding window used for the login lockout. Default is 900 (15 minutes). +login_attempt_window_seconds=900 + +; Maximum number of account registrations from a single IP address within the window below. +; Default is 2; set to 0 to disable. +max_registrations_per_ip=2 + +; Length in seconds of the registration window. Default is 3600 (1 hour). +registration_window_seconds=3600 + +; Maximum number of forgot-password requests from a single IP address within the window below. +; Default is 3; set to 0 to disable. +max_forgot_password_requests_per_ip=3 + +; Length in seconds of the forgot-password window. Default is 3600 (1 hour). +forgot_password_window_seconds=3600 + ; You may want to allow an unlimited number of users from a trusted source. This setting can contain a ; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the ; IP addresses listed (ignoring the max_users_per_address). Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74" diff --git a/servatrice/src/ratelimiter.cpp b/servatrice/src/ratelimiter.cpp new file mode 100644 index 000000000..e7f209e38 --- /dev/null +++ b/servatrice/src/ratelimiter.cpp @@ -0,0 +1,125 @@ +#include "ratelimiter.h" + +#include + +bool RateLimiter::recordAttempt(const QString &key, int maxAttempts, int windowSeconds) +{ + return recordAttemptAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch()); +} + +bool RateLimiter::recordAttemptAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) +{ + if (maxAttempts <= 0 || windowSeconds <= 0) { + return false; // rate limiting disabled for this endpoint + } + + QMutexLocker locker(&mutex); + pruneLocked(now); + + AttemptHistory &history = attempts[key]; + history.windowSeconds = windowSeconds; + QList ×tamps = history.timestamps; + while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) { + timestamps.removeFirst(); + } + if (timestamps.size() >= maxAttempts) { + // Already at the limit: reject without appending so repeated attempts + // at the attacker's own pace cannot keep sliding the window forward + // and hold the lockout open forever. + return true; + } + + timestamps.append(now); + return false; +} + +bool RateLimiter::isBlocked(const QString &key, int maxAttempts, int windowSeconds) const +{ + return isBlockedAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch()); +} + +bool RateLimiter::isBlockedAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const +{ + if (maxAttempts <= 0 || windowSeconds <= 0) { + return false; + } + + QMutexLocker locker(&mutex); + + const auto it = attempts.constFind(key); + if (it == attempts.constEnd()) { + return false; + } + + int count = 0; + for (const qint64 ×tamp : it.value().timestamps) { + if (timestamp > now - windowSeconds) { + ++count; + } + } + return count >= maxAttempts; +} + +void RateLimiter::clearAttempts(const QString &key) +{ + QMutexLocker locker(&mutex); + attempts.remove(key); +} + +int RateLimiter::retryAfterSeconds(const QString &key, int maxAttempts, int windowSeconds) const +{ + return retryAfterSecondsAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch()); +} + +int RateLimiter::retryAfterSecondsAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const +{ + if (maxAttempts <= 0 || windowSeconds <= 0) { + return 0; + } + + QMutexLocker locker(&mutex); + + const auto it = attempts.constFind(key); + if (it == attempts.constEnd()) { + return 0; + } + + QList timestamps = it.value().timestamps; + while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) { + timestamps.removeFirst(); + } + if (timestamps.size() < maxAttempts) { + return 0; + } + if (timestamps.isEmpty()) { + return 0; + } + + qint64 oldest = timestamps.first(); + qint64 retryAt = oldest + windowSeconds; + if (retryAt <= now) { + return 0; + } + return static_cast(retryAt - now); +} + +void RateLimiter::pruneLocked(qint64 now) +{ + if (lastPruneSecs + PruneIntervalSeconds > now) { + return; + } + lastPruneSecs = now; + + auto it = attempts.begin(); + while (it != attempts.end()) { + QList ×tamps = it.value().timestamps; + while (!timestamps.isEmpty() && timestamps.first() <= now - it.value().windowSeconds) { + timestamps.removeFirst(); + } + if (timestamps.isEmpty()) { + it = attempts.erase(it); + } else { + ++it; + } + } +} diff --git a/servatrice/src/ratelimiter.h b/servatrice/src/ratelimiter.h new file mode 100644 index 000000000..e2bc8fab6 --- /dev/null +++ b/servatrice/src/ratelimiter.h @@ -0,0 +1,93 @@ +#ifndef RATELIMITER_H +#define RATELIMITER_H + +#include +#include +#include +#include + +/** @brief Thread-safe per-key attempt limiter used to throttle abusive requests. */ +class RateLimiter +{ +public: + /** + * @brief Record an attempt for @p key and report whether the key is now blocked. + * + * A key that is already at its limit is not extended: the attempt is + * rejected without appending, so the lockout lifts once the recorded + * attempts age out of the window. Returns false immediately when + * @p maxAttempts <= 0 or @p windowSeconds <= 0 (rate limiting disabled), + * recording nothing. + * + * Delegates to recordAttemptAt() with the current wall-clock time. + */ + bool recordAttempt(const QString &key, int maxAttempts, int windowSeconds); + + /** + * @brief Record an attempt as seen at epoch second @p now. + * + * Exposed for deterministic tests; production callers should use + * recordAttempt(). + */ + bool recordAttemptAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now); + + /** + * @brief True if @p key already has at least @p maxAttempts attempts within @p windowSeconds. + * + * Reads only; records nothing. Returns false when @p maxAttempts <= 0 or + * @p windowSeconds <= 0. Delegates to isBlockedAt(). + */ + bool isBlocked(const QString &key, int maxAttempts, int windowSeconds) const; + + /** + * @brief Whether @p key is blocked as measured at epoch second @p now. + * + * Exposed for deterministic tests; production callers should use + * isBlocked(). + */ + bool isBlockedAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const; + + /** @brief Drop all recorded attempts for the key, e.g. after a successful login. */ + void clearAttempts(const QString &key); + + /** + * @brief Get the retry-after window in seconds for @p key. + * + * Returns the number of seconds from @p now until the oldest attempt + * exits @p windowSeconds and the key is no longer blocked. If the key is + * not blocked, returns 0. Returns 0 if @p maxAttempts <= 0 or + * @p windowSeconds <= 0. + * + * Delegates to retryAfterSecondsAt(). + */ + int retryAfterSeconds(const QString &key, int maxAttempts, int windowSeconds) const; + + /** + * @brief Get the retry-after window in seconds for @p key as measured at @p now. + * + * Exposed for deterministic tests. + */ + int retryAfterSecondsAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const; + +private: + struct AttemptHistory + { + QList timestamps; // epoch seconds, oldest first + int windowSeconds; + }; + + /** + * @brief Drops keys whose last attempts have fully aged out of their window. + * + * Runs at most every PruneIntervalSeconds to stop the map from growing + * without bound when attackers rotate source addresses. + */ + void pruneLocked(qint64 now); + + mutable QMutex mutex; + QMap attempts; // key -> attempt history + qint64 lastPruneSecs = 0; + static constexpr int PruneIntervalSeconds = 60; +}; + +#endif diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 9ec9ef5bc..a832c1652 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -51,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -1286,6 +1287,105 @@ int Servatrice::getForgotPasswordTokenLife() const return settingsCache->value("forgotpassword/tokenlife", 60).toInt(); } +/** + * @brief Rate limiter key for one login target. + * + * Keyed by (address, username) so a success only clears that pair's history: + * a guest login or a login as the attacker's own account from the same + * address must not wipe out another user's failure count. Lowercased so + * case variations of the same name share one bucket. + */ +static QString loginLimitKey(const QString &ipAddress, const QString &userName) +{ + return QStringLiteral("login:") + ipAddress + QStringLiteral(":") + userName.toLower(); +} + +bool Servatrice::recordFailedLogin(const QString &ipAddress, const QString &userName) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.recordAttempt(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(), + getLoginAttemptWindowSeconds()); +} + +bool Servatrice::isLoginRateLimited(const QString &ipAddress, const QString &userName) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.isBlocked(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(), + getLoginAttemptWindowSeconds()); +} + +int Servatrice::loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName) +{ + return rateLimiter.retryAfterSeconds(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(), + getLoginAttemptWindowSeconds()); +} + +bool Servatrice::recordRegistrationAttempt(const QString &ipAddress) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.recordAttempt("register:" + ipAddress, getMaxRegistrationsPerIp(), + getRegistrationWindowSeconds()); +} + +bool Servatrice::recordForgotPasswordAttempt(const QString &ipAddress) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.recordAttempt("forgot:" + ipAddress, getMaxForgotPasswordRequestsPerIp(), + getForgotPasswordWindowSeconds()); +} + +bool Servatrice::isRateLimitExempt(const QString &ipAddress) const +{ + // Mirrors the max_users_per_address bypass in initSession: trusted sources + // (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket + // client shares one address) are not subject to per-address limits. + const QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); + return trustedSources.contains(ipAddress, Qt::CaseInsensitive); +} + +void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName) +{ + rateLimiter.clearAttempts(loginLimitKey(ipAddress, userName)); +} + +int Servatrice::getMaxLoginAttemptsPerIp() const +{ + return settingsCache->value("security/max_login_attempts_per_ip", 5).toInt(); +} + +int Servatrice::getLoginAttemptWindowSeconds() const +{ + return settingsCache->value("security/login_attempt_window_seconds", 900).toInt(); +} + +int Servatrice::getMaxRegistrationsPerIp() const +{ + return settingsCache->value("security/max_registrations_per_ip", 2).toInt(); +} + +int Servatrice::getRegistrationWindowSeconds() const +{ + return settingsCache->value("security/registration_window_seconds", 3600).toInt(); +} + +int Servatrice::getMaxForgotPasswordRequestsPerIp() const +{ + return settingsCache->value("security/max_forgot_password_requests_per_ip", 3).toInt(); +} + +int Servatrice::getForgotPasswordWindowSeconds() const +{ + return settingsCache->value("security/forgot_password_window_seconds", 3600).toInt(); +} + bool Servatrice::getEnableForgotPasswordChallenge() const { return settingsCache->value("forgotpassword/enablechallenge", false).toBool(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 205a4f04d..d5414f810 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -21,6 +21,7 @@ #define SERVATRICE_H #include "metrics_registry.h" +#include "ratelimiter.h" #include "smtpclient.h" #include @@ -204,6 +205,8 @@ private: std::shared_ptr reportStatsCache; static constexpr int reportStatsCacheTtlSeconds = 60; + RateLimiter rateLimiter; + mutable QMutex serverListMutex; QList serverList; void updateServerList(); @@ -362,6 +365,25 @@ public: */ void watchWorkerThread(QThread *thread); + RateLimiter *getRateLimiter() + { + return &rateLimiter; + } + bool recordFailedLogin(const QString &ipAddress, const QString &userName) override; + bool isLoginRateLimited(const QString &ipAddress, const QString &userName) override; + int loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName) override; + void clearFailedLogins(const QString &ipAddress, const QString &userName) override; + bool recordRegistrationAttempt(const QString &ipAddress); + bool recordForgotPasswordAttempt(const QString &ipAddress); + /// True when the address is in security/trusted_sources and therefore exempt from per-address limits. + bool isRateLimitExempt(const QString &ipAddress) const; + int getMaxLoginAttemptsPerIp() const; + int getLoginAttemptWindowSeconds() const; + int getMaxRegistrationsPerIp() const; + int getRegistrationWindowSeconds() const; + int getMaxForgotPasswordRequestsPerIp() const; + int getForgotPasswordWindowSeconds() const; + bool islConnectionExists(int _serverId) const; void addIslInterface(int _serverId, IslInterface *interface); void removeIslInterface(int _serverId); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 5f5d1ced5..bf8d02fc7 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -3263,6 +3263,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C false); } + Response_Register *re = new Response_Register; + re->set_denied_reason_str("Too many registration attempts"); + int remaining = servatrice->getRateLimiter()->retryAfterSeconds("register:" + this->getAddress(), + servatrice->getMaxRegistrationsPerIp(), + servatrice->getRegistrationWindowSeconds()); + if (remaining > 0) { + re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch()); + } + rc.setResponseExtension(re); return Response::RespTooManyRequests; } @@ -3330,9 +3339,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &ipAddress) { - //! \todo Implement registration attempt limiting. - Q_UNUSED(ipAddress); - return false; + return servatrice->recordRegistrationAttempt(ipAddress); } Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd, @@ -3687,6 +3694,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordRequest(c qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password request from user:" << userName; + if (servatrice->recordForgotPasswordAttempt(this->getAddress())) { + if (servatrice->getEnableForgotPasswordAudit()) { + sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), + "PASSWORD_RESET_REQUEST", "Too many requests from this ip address", false); + } + + return Response::RespTooManyRequests; + } + if (!servatrice->getEnableForgotPassword()) { if (servatrice->getEnableForgotPasswordAudit()) { sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), @@ -3829,6 +3845,14 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password challenge from user:" << userName; + if (servatrice->recordForgotPasswordAttempt(this->getAddress())) { + if (servatrice->getEnableForgotPasswordAudit()) { + sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), + "PASSWORD_RESET_CHALLENGE", "Too many requests from this ip address", false); + } + return Response::RespTooManyRequests; + } + if (!servatrice->getEnableForgotPasswordChallenge()) { if (servatrice->getEnableForgotPasswordAudit()) { sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9955e1189..784aac3be 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -196,6 +196,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + rate_limiter_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/ratelimiter.cpp + rate_limiter_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME server_card_counter_test diff --git a/tests/rate_limiter_test.cpp b/tests/rate_limiter_test.cpp new file mode 100644 index 000000000..b1fb995f5 --- /dev/null +++ b/tests/rate_limiter_test.cpp @@ -0,0 +1,124 @@ +#include "ratelimiter.h" + +#include "gtest/gtest.h" + +namespace +{ + +TEST(RateLimiterTest, AllowsAttemptsWithinLimit) +{ + RateLimiter limiter; + ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1000)); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1001)); + // Still under the limit after two attempts, so the key is not blocked... + ASSERT_FALSE(limiter.isBlockedAt("ip", 3, 60, 1002)); + // ...and the third (the limit) is allowed through. + ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1002)); + // The next attempt is blocked once three are on the books. + ASSERT_TRUE(limiter.isBlockedAt("ip", 3, 60, 1003)); + ASSERT_TRUE(limiter.recordAttemptAt("ip", 3, 60, 1003)); +} + +TEST(RateLimiterTest, BlocksAttemptsAtTheLimit) +{ + RateLimiter limiter; + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, 1000)); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, 1001)); + ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, 60, 1002)); + ASSERT_TRUE(limiter.isBlockedAt("ip", 2, 60, 1002)); +} + +TEST(RateLimiterTest, NonPositiveConfigDisablesLimiting) +{ + RateLimiter limiter; + // maxAttempts = 0 is the documented "set to 0 to disable"; negative or + // zero windows are config mistakes and must not lock everyone out either. + ASSERT_FALSE(limiter.recordAttempt("ip", 0, 60)); + ASSERT_FALSE(limiter.isBlocked("ip", 0, 60)); + ASSERT_FALSE(limiter.recordAttempt("ip", -1, 60)); + ASSERT_FALSE(limiter.isBlocked("ip", -1, 60)); + ASSERT_FALSE(limiter.recordAttempt("ip", 5, 0)); + ASSERT_FALSE(limiter.isBlocked("ip", 5, 0)); +} + +TEST(RateLimiterTest, ClearAttempts) +{ + RateLimiter limiter; + ASSERT_FALSE(limiter.recordAttemptAt("ip", 1, 60, 1000)); + ASSERT_TRUE(limiter.recordAttemptAt("ip", 1, 60, 1001)); + ASSERT_TRUE(limiter.isBlockedAt("ip", 1, 60, 1001)); + limiter.clearAttempts("ip"); + ASSERT_FALSE(limiter.isBlockedAt("ip", 1, 60, 1002)); + // The cleared key starts fresh and is allowed again. + ASSERT_FALSE(limiter.recordAttemptAt("ip", 1, 60, 1002)); +} + +TEST(RateLimiterTest, KeysAreIndependent) +{ + RateLimiter limiter; + ASSERT_FALSE(limiter.recordAttemptAt("a", 1, 60, 1000)); + ASSERT_TRUE(limiter.recordAttemptAt("a", 1, 60, 1001)); + ASSERT_FALSE(limiter.isBlockedAt("b", 1, 60, 1001)); + ASSERT_TRUE(limiter.isBlockedAt("a", 1, 60, 1001)); +} + +TEST(RateLimiterTest, AttemptsAgeOutOfTheWindow) +{ + RateLimiter limiter; + const qint64 t = 1000; + const int window = 60; + + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t)); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 1)); + ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, window, t + 2)); + ASSERT_TRUE(limiter.isBlockedAt("ip", 2, window, t + 2)); + + // A blocked attempt is rejected without recording, so it cannot extend + // the lockout either. + ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, window, t + 3)); + + // Once the recorded attempts fall outside the window the key clears by + // itself, with no manual unlock. + ASSERT_FALSE(limiter.isBlockedAt("ip", 2, window, t + 61)); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 62)); +} + +TEST(RateLimiterTest, BlockedKeyIsUnlockedAfterSuccessfulClear) +{ + RateLimiter limiter; + const qint64 t = 5000; + + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t)); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t + 1)); + ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, 60, t + 2)); + ASSERT_TRUE(limiter.isBlockedAt("ip", 2, 60, t + 2)); + + limiter.clearAttempts("ip"); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t + 3)); +} + +TEST(RateLimiterTest, RetryAfterSecondsReportsTimeUntilUnlock) +{ + RateLimiter limiter; + const qint64 t = 1000; + const int window = 60; + + ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t)); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t)); + ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t)); + ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 1)); + ASSERT_TRUE(limiter.isBlockedAt("ip", 2, window, t + 2)); + // Oldest attempt was at t, window 60, so retry at t+60; now is t+2 => 58. + ASSERT_EQ(58, limiter.retryAfterSecondsAt("ip", 2, window, t + 2)); + // At t+60 the window just elapsed; retry should return 0. + ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t + 60)); + ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t + 61)); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file From bbe438b09a59172589c9a747cf8ead4913471c59 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:01:38 +0200 Subject: [PATCH 035/247] [Completer] Highlight popup rows without rewriting the line edit (#7502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Completer] Highlight popup rows without rewriting the line edit Selecting a popup row with ClearAndSelect emits selectionChanged, which QCompleter turns into highlighted() and QLineEdit turns into setText(). Since be2c16eca5 (#7466) that rewrites the typed text on every completion reset / arrow-key press (e.g. typing "A" becomes "Axe" and cannot be edited), regressing the 514014dc00 (#7447) fix that used NoUpdate. Add selectWithoutRewrite(), which blocks the selection-model signals while applying ClearAndSelect so the highlight stays visible without touching the line edit, then syncs the preview manually. Route both the above-popup auto-select and Up/Down navigation through it, and handle Up/Down for below-popups too instead of falling through to Qt's rewriting default. Enter/click acceptance via activated() is unchanged. * [Completer] Repaint popup highlight after silent selection change Blocking the selection model's signals also suppresses the view's own selectionChanged/currentChanged repaints, so force a viewport update after restoring signals. Use EnsureVisible to match Qt's normal scroll behaviour. --------- Co-authored-by: Lukas Brübach --- .../widgets/utility/card_completer_styler.cpp | 61 ++++++++++++++----- .../widgets/utility/card_completer_styler.h | 1 + 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 8aa32556f..5b33303a0 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -133,29 +133,33 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event) return false; } - if (!above) { - return false; - } - auto *popup = completer->popup(); const int rowCount = completer->completionModel()->rowCount(); if (rowCount == 0) { return true; } + // Handle navigation ourselves so arrow keys move the highlight without + // rewriting the line edit. QCompleter's default handling selects with a + // selection change that QLineEdit turns into setText(), which fights the + // user while typing (e.g. typing "A" becomes "Axe" and cannot be edited). // With the popup above the text edit the rows are shown in reverse order and // the closest match is in the bottom-most row. The up arrow then advances // through the list (towards its end at the top of the popup). - const int currentRow = popup->currentIndex().row(); - const int step = (key == Qt::Key_Up) ? -1 : 1; - const int newRow = qBound(0, currentRow + step, rowCount - 1); - - if (newRow != currentRow) { - const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn()); - popup->selectionModel()->setCurrentIndex(newIndex, - QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + const QModelIndex current = popup->currentIndex(); + int newRow; + if (!current.isValid()) { + newRow = (key == Qt::Key_Down) ? 0 : rowCount - 1; + } else { + const int step = (key == Qt::Key_Up) ? -1 : 1; + newRow = qBound(0, current.row() + step, rowCount - 1); + if (newRow == current.row()) { + return true; + } } + selectWithoutRewrite(completer->completionModel()->index(newRow, completer->completionColumn())); + return true; } @@ -218,8 +222,37 @@ void CardCompleterStyler::ensureClosestSelected() const int closestRow = rowCount - 1; if (currentRow != closestRow) { - popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()), - QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + selectWithoutRewrite(completionModel->index(closestRow, completer->completionColumn())); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::selectWithoutRewrite(const QModelIndex &index) +{ + auto *popup = completer->popup(); + auto *selectionModel = popup->selectionModel(); + + // Changing the popup selection emits selectionChanged, which QCompleter + // turns into highlighted() and QLineEdit turns into setText(). That + // rewrites what the user typed (e.g. "A" becomes "Axe") and fights further + // typing. Block the signals so the highlight and preview move without + // touching the line edit; Enter/click still activates via activated(). + const bool wasBlocked = selectionModel->blockSignals(true); + if (!index.isValid()) { + selectionModel->clear(); + } else { + selectionModel->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } + selectionModel->blockSignals(wasBlocked); + popup->viewport()->update(); + + // currentRowChanged was suppressed above, so sync the preview manually. + updatePreview(index); + if (index.isValid()) { + popup->scrollTo(index, QAbstractItemView::EnsureVisible); + } else { + popup->scrollToTop(); } } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.h b/cockatrice/src/interface/widgets/utility/card_completer_styler.h index 2912f7312..04310e676 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.h @@ -55,6 +55,7 @@ private: void applyPopupWidth(); void updateOrientation(); void ensureClosestSelected(); + void selectWithoutRewrite(const QModelIndex &index); bool handlePopupKeyPress(QKeyEvent *event); bool isPopupAboveWidget() const; From b11b096828627c58fcb2ffcbcda33d0a48e383e4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:50:43 +0200 Subject: [PATCH 036/247] [Server] Apply card art rules to playmats (#7532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Apply card art rules to playmats Card art DENY rules were only enforced for user card art (SetCardArtParams) while playmats using the same art bypassed them. Move the rule lookup to Servatrice_DatabaseInterface::isCardArtAllowed with a default-allow base on Server_DatabaseInterface, delegate the session check to it, reject DENY-listed Command_SetPlaymat with RespFunctionNotAllowed, and strip DENY-listed deck playmats on DeckSelect so banned art is never broadcast. * [Server] Address review nits on playmat card art rules --------- Co-authored-by: Lukas Brübach --- .../server/remote/game/server_player.cpp | 21 ++++++++++++++++++ .../server/remote/server_database_interface.h | 10 +++++++++ .../src/servatrice_database_interface.cpp | 22 +++++++++++++++++++ .../src/servatrice_database_interface.h | 1 + servatrice/src/serversocketinterface.cpp | 22 +------------------ servatrice/src/serversocketinterface.h | 1 - 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index a4c904c01..949d04b4b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -257,6 +257,19 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r return Response::RespInternalError; } + // Card art rules apply to playmats as well: never broadcast a DENY-listed + // printing as a playmat. Stripping keeps the deck selectable while hiding + // the banned art from all clients. + const auto deckPlaymat = newDeck->getPlaymat(); + if (!deckPlaymat.card.name.isEmpty()) { + auto *db = game->getRoom()->getServer()->getDatabaseInterface(); + // Check the truncated values actually broadcast below; the rule columns are varchar(255). + if (db && !db->isCardArtAllowed(deckPlaymat.card.name.left(MAX_NAME_LENGTH), + deckPlaymat.card.providerId.left(MAX_NAME_LENGTH))) { + newDeck->setPlaymat({}); + } + } + delete deck; deck = newDeck; sideboardLocked = true; @@ -631,6 +644,14 @@ Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &r if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) { return Response::RespInvalidData; } + // Card art rules apply to playmats as well: reject DENY-listed art, like + // cmdSetCardArtParams does. Empty clears the playmat and is always allowed. + if (!rawName.isEmpty()) { + auto *db = game->getRoom()->getServer()->getDatabaseInterface(); + if (db && !db->isCardArtAllowed(rawName, rawProviderId)) { + return Response::RespFunctionNotAllowed; + } + } PlaymatInfo playmat; playmat.card.name = rawName; playmat.card.providerId = rawProviderId; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index e0be7990b..167adfeaf 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -188,6 +188,16 @@ public: virtual void setForcePasswordChange(const QString & /* user */, bool /* force */) { } + /** + * @brief Whether the given card art may be shown (card art rules). + * + * Default allows everything; the real server overrides this with a lookup + * in {prefix}_card_art_name_rules. Empty names are always allowed (clear path). + */ + virtual bool isCardArtAllowed(const QString & /* cardName */, const QString & /* providerId */) + { + return true; + } }; #endif diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index ac3a19c8e..e7d46c050 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -2068,3 +2068,25 @@ void Servatrice_DatabaseInterface::addAuditRecord(const QString &user, query->bindValue(":details", details); execSqlQuery(query); } + +bool Servatrice_DatabaseInterface::isCardArtAllowed(const QString &cardName, const QString &cardProviderId) +{ + if (cardName.isEmpty() || !checkSql()) { + return true; + } + QSqlQuery *q = prepareQuery( + "SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider"); + q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); + + if (!execSqlQuery(q)) { + qWarning() << "Card art rule lookup failed; failing open for" << cardName; + return true; + } + + if (!q->next()) { + return true; // default allow + } + + return q->value(0).toString() != "DENY"; +} diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0509d1a03..411360fa7 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -218,6 +218,7 @@ public: }; UptimeSnapshot getLatestUptimeSnapshot(int serverId); bool removeUserAvatar(const QString &userName); + bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override; bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; bool doesForgotPasswordExist(const QString &user); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index bf8d02fc7..b4d12599d 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -3481,26 +3481,6 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm return Response::RespOk; } -bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName, const QString &cardProviderId) -{ - QSqlQuery *q = sqlInterface->prepareQuery( - "SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider"); - - q->bindValue(":name", cardName); - q->bindValue(":provider", cardProviderId); - - if (!sqlInterface->execSqlQuery(q)) { - qWarning() << "Card art rule lookup failed; failing open for" << cardName; - return true; - } - - if (!q->next()) { - return true; // default allow - } - - return q->value(0).toString() != "DENY"; -} - Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer & /* rc */) { @@ -3527,7 +3507,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const return Response::RespOk; } - if (!isCardNameAllowed(cardName, cardProviderId)) { + if (!sqlInterface->isCardArtAllowed(cardName, cardProviderId)) { return Response::RespFunctionNotAllowed; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 7de742c66..586bff831 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -216,7 +216,6 @@ private: Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); - bool isCardNameAllowed(const QString &cardName, const QString &cardProviderId); Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &); Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &); Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &); From 711386574d80bfe0bcdd502bed259732f6c0d7a2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:50:57 +0200 Subject: [PATCH 037/247] [Client] Tell the user when their playmat art is blocked (#7533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Tell the user when their playmat art is blocked A server DENY rule silently drops banned playmat art: SetPlaymat fails with no handler and DeckSelect strips the mat while answering RespOk. Surface both cases with the same critical message box the banner card art flow uses. DeckSelect strips are detected by comparing the requested mat, passed via PendingCommand extraData, against the server-echoed deck. Notices dedupe per card and reset on success. * [Client] Gate playmat blocked notice on RespFunctionNotAllowed per review Only SetPlaymat with RespFunctionNotAllowed signals a card art DENY rule. Older servers answer RespInvalidCommand and strip the mat from DeckSelect, so drop the DeckSelect comparison to avoid false blacklisted popups on pre-playmat servers. --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../deckview/deck_view_container.cpp | 22 +++++++++++++++++++ .../deckview/deck_view_container.h | 8 ++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index c97767ec7..a6e541fca 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -354,6 +354,19 @@ void DeckViewContainer::resolveAndSendPlaymat() pp->set_vertical_offset(resolved.params.verticalOffset); pp->set_zoom(resolved.params.zoom); PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd); + 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 + // playmats answer RespInvalidCommand and strip the mat from DeckSelect, which must stay silent. + if (r.response_code() == Response::RespFunctionNotAllowed) { + if (!sentName.isEmpty()) { + notifyPlaymatBlacklisted( + sentName, tr("The selected playmat card is blacklisted on this server, so it was not applied.")); + } + } else if (r.response_code() == Response::RespOk && !sentName.isEmpty()) { + lastBlacklistedPlaymatNotice.clear(); + } + }); parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId); } @@ -412,6 +425,15 @@ void DeckViewContainer::deckSelectFinished(const Response &r) switchToDeckLoadedView(); } +void DeckViewContainer::notifyPlaymatBlacklisted(const QString &cardName, const QString &message) +{ + if (cardName.isEmpty() || cardName == lastBlacklistedPlaymatNotice) { + return; + } + lastBlacklistedPlaymatNotice = cardName; + QMessageBox::critical(this, tr("Error"), message); +} + void DeckViewContainer::readyStart() { sendReadyStartCommand(!readyStartButton->getState()); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.h b/cockatrice/src/game_graphics/deckview/deck_view_container.h index 80b82b6e5..c064a5b5a 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -60,12 +60,14 @@ private: VisualDeckStorageWidget *visualDeckStorageWidget; TabGame *parentGame; int playerId; - int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode. - DeckList currentDeck; ///< Cached deck for live settings re-resolution. - PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode. + int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode. + DeckList currentDeck; ///< Cached deck for live settings re-resolution. + PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode. + QString lastBlacklistedPlaymatNotice; ///< Card name already reported as blacklisted, to avoid repeat popups. void tryCreateVisualDeckStorageWidget(); void sendReadyStartCommand(bool ready); + void notifyPlaymatBlacklisted(const QString &cardName, const QString &message); private slots: void switchToDeckSelectView(); void switchToDeckLoadedView(); From 6c660d6b21f2dec69ea34cddd84e08634d024d59 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:05:42 +0200 Subject: [PATCH 038/247] [Installer][Client] Back up user data before old uninstallers can delete it (#7503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer][Client] Back up user data before old uninstallers can delete it * [Installer][Client] Fix stale backup resurrection, batch session backups, extend coverage NSIS: clear stale %TEMP% backup and only arm restore when something was copied, directory-existence checks, back up loose .dec decks, drop downloaded pics but keep pics\CUSTOM. Helper: single session dir per run (ms timestamps), same-basename disambiguation, partial copies still report their location. * [Installer][Client] Fix review findings: throttle backups, NSIS old dir, portable messaging * [Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap * [Installer][Client] Fix review findings: sounds, session coverage, NSIS wording Client: back up data/sounds in portable mode and mention it in the warning; reuse a recent session only when complete and covering the current sources (source manifest), estimate size only for fresh copies, normalize dedup via isInsideDir. Helper: source manifest per session with sessionCoversSources(), clear out-params on all failure paths. NSIS: document defensive portable branch, merge backup over surviving data/ to repair partial deletes, copy (not move) wording in relocate, note .txt exclusion in deck notice. * [Installer][Client] Fix review findings: stale backup, empty reuse, relocate, portable warning NSIS: wipe TEMP backup once per run to avoid resurrecting deleted files, check data dir existence in relocate so dir-only trees are moved. Client: never reuse a recent session for an empty source set, only fall back to a covering session on copy failure, warn fresh portable installs instead of staying silent. * [Installer][Client] Fix review findings: usable session reuse, loose decks, NSIS pics, symlinks * [Installer][Client] Fix portable backup exclusions in offender merge * [Client] Split at-risk data warning into focused helpers Ricky's nit on the 200+ line warnAboutDataPathsInProgramDir: extract collection (portable/installed), backup, message building and dialog into named helpers. No behavior change, all tr() strings untouched. * [Client] Read path settings inside isPortableBackupExcluded * [Client] Make stateless at-risk helpers static * [Client] Extract at-risk data warning out of MainWindow * [Installer][Client] Fix review findings: subdir backup, portable warning, backup API NSIS: - Back up unknown top-level install subdirs (e.g. \decks), not just loose deck files, so the 3.1.1 -> new upgrade no longer loses data folders the old RMDir /r deletes before the client ever runs. Payload dirs (plugins/translations/sounds/themes) stay in place to avoid restoring stale DLLs; restore + relocate handle the new backup set and the one-time notice covers both decks and folders. - Restart the %TEMP% backup when the 32/64-bit passes point at different dirs instead of merging (which restored one dir's files into the other); document that only the last pass relocates and the %TEMP% copy is not a durable second copy. Client: - Drop the portable at-risk warning: portable installs never shipped an uninstaller and portable updates preserve data/, so every portable user got a spurious dialog plus daily host-machine copies. - AtRiskDataWarning is now a plain class with Q_DECLARE_TR_FUNCTIONS instead of QObject+Q_OBJECT; logging category renamed to at_risk_data_warning; MainWindow wrapper inlined into startupConfigCheck. - Lower the synchronous startup backup cap 500 -> 200 MB. Utility/tests: - Remove test-only backupAtRiskPath()/findRecentBackupSession() and the no-marker/no-manifest legacy paths; isBackupSessionComplete() now requires the complete marker; tests updated to the plural API. * [Client] Restore collectInstalledAtRiskPaths per review a23e02b5 removed the definition along with the portable helpers, breaking the build (undefined reference at link time, and isInsideAnyDir unused under -Werror on Arch/Debian 13). Restores the 202c0d65 version (pathsInsideDir plus loose top-level deck files) as suggested. --------- Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 351 ++++++++++- cockatrice/CMakeLists.txt | 2 + .../src/interface/at_risk_data_warning.cpp | 215 +++++++ .../src/interface/at_risk_data_warning.h | 50 ++ cockatrice/src/interface/window_main.cpp | 35 +- cockatrice/src/interface/window_main.h | 3 - libcockatrice_utility/CMakeLists.txt | 2 + .../utility/user_data_backup.cpp | 405 ++++++++++++ .../libcockatrice/utility/user_data_backup.h | 132 ++++ tests/CMakeLists.txt | 1 + tests/user_data_backup_test.cpp | 581 ++++++++++++++++++ 11 files changed, 1734 insertions(+), 43 deletions(-) create mode 100644 cockatrice/src/interface/at_risk_data_warning.cpp create mode 100644 cockatrice/src/interface/at_risk_data_warning.h create mode 100644 libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/user_data_backup.h create mode 100644 tests/user_data_backup_test.cpp diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index b6125238f..811a3cb7f 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,8 +15,17 @@ Var ReinstallMode Var AllowForceClose Var GuardDir Var OldUninstallString +Var OldInstallDir +Var DataBackupDir +Var DataBackupDone +Var DataBackupForDir +Var LooseDecksBackupDone +Var InstallSubdirsBackupDone +Var LooseDeckNoticeShown Var StaleFindHandle Var StaleFindName +Var DataBackupFindHandle +Var DataBackupFindName Var IsUpgrade Var AbortReason Var WaitPid @@ -125,6 +134,17 @@ FunctionEnd Function .onInit StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log" +; No backup has been taken yet; RestoreUserDataAfterUninstall must be a no-op +; until BackupUserDataBeforeUninstall sets this after actually copying something. +StrCpy $DataBackupDir "" +StrCpy $DataBackupDone "" +StrCpy $DataBackupForDir "" +StrCpy $LooseDecksBackupDone "" +StrCpy $InstallSubdirsBackupDone "" +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 "" ; 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 @@ -314,10 +334,15 @@ SetShellVarContext all SetRegView 32 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" -!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'" +; On the /R path the updater passes /D=, so $INSTDIR already is the old +; install dir. Publish it for the backup/restore pair below. +StrCpy $OldInstallDir $INSTDIR +!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." -ExecWait '$R0 _?=$INSTDIR' +Call BackupUserDataBeforeUninstall +ExecWait '$R0 _?=$OldInstallDir' +Call RestoreUserDataAfterUninstall done32: @@ -326,16 +351,312 @@ ${If} ${RunningX64} SetRegView 64 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" - !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'" + StrCpy $OldInstallDir $INSTDIR + !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." - ExecWait '$R0 _?=$INSTDIR' + Call BackupUserDataBeforeUninstall + ExecWait '$R0 _?=$OldInstallDir' + Call RestoreUserDataAfterUninstall done64: ${EndIf} FunctionEnd +; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall +; +; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR". +; For a portable install that deletes $INSTDIR\data (decks, settings, pictures) +; along with the payload, and for a normal install it deletes anything the user +; pointed into $INSTDIR. The fixed uninstaller preserves foreign files, but the +; destructive one is already on disk on affected machines, so stash a copy +; outside $INSTDIR before invoking it and put it back afterwards. Best effort +; only: a failed backup must never abort the install. +; +; The backup is left in %TEMP% afterwards on a best-effort basis only: the next +; installer run that takes a new backup wipes it first (see below), so it is not +; a durable second copy. Anything this backup does not cover - e.g. loose files +; with other extensions - relies on the client's startup safety backup +; (warnAboutDataPathsInProgramDir), which only exists after the first launch of +; the new client. +; +; The 32-bit and 64-bit uninstall blocks below share $TEMP\CockatriceDataBackup. +; When both are registered with different InstallLocations, backing up the second +; directory into the first one's backup would restore the first dir's files into +; the second one. BackupUserDataBeforeUninstall therefore starts over when +; $OldInstallDir changes: the previous pass already restored its own backup, so +; only its leftover %TEMP% copy is lost. RelocateRestoredDataToInstDir only sees +; the last pass, so with two different old dirs plus a new target dir the first +; dir's restored data stays in place instead of moving along - no loss, but the +; user has to copy it manually. +; +; The portable-data branch below is defensive: both callers (AutoUninstallIfNeeded +; on /R, componentsPagePre interactively) only run for non-portable installs, and +; portable installs never write the registry uninstall keys that trigger them. +; It fires only when $OldInstallDir nevertheless contains portable.dat - e.g. a +; stale registry InstallLocation pointing at a portable dir, or portable.dat added +; to a normal install dir. The loose-decks and install-subdirs branches are the +; live ones for normal installs. +Function BackupUserDataBeforeUninstall +; $OldInstallDir is the directory being uninstalled: on the /R path it equals +; $INSTDIR (updater passes /D=), on the interactive path componentsPagePre +; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is +; still the default there (directory page comes later). +${If} $OldInstallDir == "" + StrCpy $OldInstallDir $INSTDIR +${EndIf} +; %TEMP% survives across installer runs, so a previous run's safety copy would +; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files +; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B +; from run 2) or restoring stale content after a failed copy. Wipe once per +; installer run - $DataBackupDir is empty until the first backup of this run, +; so the second call in sequence (32-bit then 64-bit uninstall) still merges. +${If} $DataBackupDir == "" + RMDir /r "$TEMP\CockatriceDataBackup" +${EndIf} +; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second +; block points at a different directory, merging would restore the first dir's +; files into the second one, so start over instead. The previous pass already +; restored its own backup; only its leftover %TEMP% copy is discarded. +; StrCmp is case-sensitive while Windows paths are not, so a casing-only +; difference triggers a harmless redundant re-backup of the same content. +${If} $DataBackupDir != "" +${AndIf} $DataBackupForDir != "" +${AndIf} $OldInstallDir != $DataBackupForDir + !insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup" + RMDir /r "$TEMP\CockatriceDataBackup" + StrCpy $DataBackupDir "" + StrCpy $DataBackupDone "" + StrCpy $LooseDecksBackupDone "" + StrCpy $InstallSubdirsBackupDone "" +${EndIf} +${If} ${FileExists} "$OldInstallDir\portable.dat" +${AndIf} ${FileExists} "$OldInstallDir\data" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + StrCpy $DataBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)" + DetailPrint "Backing up portable user data before removing previous version..." + CreateDirectory "$DataBackupDir\data" + ; Copy every top-level entry except pics (downloaded pictures are large and + ; re-downloadable): copying everything first and deleting pics afterwards + ; would duplicate gigabytes into %TEMP% just to throw them away. + FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*" + ${If} $DataBackupFindHandle != "" + backup_data_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} $DataBackupFindName != "pics" + ${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*" + CreateDirectory "$DataBackupDir\data\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\" + ${Else} + CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\" + ${EndIf} + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 backup_data_loop + FindClose $DataBackupFindHandle + ${EndIf} + ; Keep user-made custom pics (data\pics\CUSTOM). + ${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM" + CreateDirectory "$DataBackupDir\data\pics\CUSTOM" + CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\" + ${EndIf} + DetailPrint "Portable user data backup complete." +${EndIf} +; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a +; blanket *.txt copy would also grab payload-adjacent text files, while the +; deck-specific extensions are unambiguous user content. +${If} ${FileExists} "$OldInstallDir\*.cod" +${OrIf} ${FileExists} "$OldInstallDir\*.dec" +${OrIf} ${FileExists} "$OldInstallDir\*.dek" +${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" + ${If} $DataBackupDir == "" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + ${EndIf} + StrCpy $LooseDecksBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying loose deck files" + DetailPrint "Backing up deck files stored in the install directory..." + CreateDirectory "$DataBackupDir\loose_decks" + CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\" +${EndIf} +; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the +; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the +; client's startup backup has never run yet, so stash every top-level directory +; that is not part of the payload. Payload dirs stay in place: they are rewritten +; by the section below, and restoring them could put stale DLLs next to the new +; executables. "data" is skipped once the portable branch above already took it; +; without portable.dat it is user content like any other unknown dir. +FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*" +${If} $DataBackupFindHandle != "" +backup_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} $DataBackupFindName != "plugins" + ${AndIf} $DataBackupFindName != "Plugins" + ${AndIf} $DataBackupFindName != "translations" + ${AndIf} $DataBackupFindName != "sounds" + ${AndIf} $DataBackupFindName != "themes" + ${If} $DataBackupFindName == "data" + ${AndIf} $DataBackupDone == "1" + Goto backup_subdirs_next + ${EndIf} + ${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" + ${If} $DataBackupDir == "" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + ${EndIf} + StrCpy $InstallSubdirsBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName" + DetailPrint "Backing up $DataBackupFindName stored in the install directory..." + CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\" + ${EndIf} + ${EndIf} +backup_subdirs_next: + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 backup_subdirs_loop + FindClose $DataBackupFindHandle +${EndIf} +; Remember which directory the current backup belongs to, so a later pass for a +; different directory starts over instead of merging (see above). +${If} $DataBackupDir != "" + StrCpy $DataBackupForDir $OldInstallDir +${EndIf} +FunctionEnd + +Function RestoreUserDataAfterUninstall +StrCmp $DataBackupDir "" done +${If} $OldInstallDir == "" + StrCpy $OldInstallDir $INSTDIR +${EndIf} +${If} $DataBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\data" + ${If} ${FileExists} "$OldInstallDir\data" + ; Existence-only: a partially-deleted data dir (locked files during the old + ; uninstall) looks the same as a survived one, so the backup is always kept. + ; Merge the backup back over it to repair partial deletes; CopyFiles only + ; adds/overwrites, so a fully-survived dir is unaffected. + !insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it" + CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" + DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data." + ${Else} + !insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data" + CreateDirectory "$OldInstallDir\data" + CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" + DetailPrint "Restored portable user data from pre-uninstall backup." + ${EndIf} +${EndIf} +${If} $LooseDecksBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\loose_decks" + !insertmacro CockLog "post-uninstall restore: restoring loose deck files" + CreateDirectory "$OldInstallDir" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\" + DetailPrint "Restored deck files stored in the install directory." +${EndIf} +${If} $InstallSubdirsBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" + !insertmacro CockLog "post-uninstall restore: restoring install subdirectories" + CreateDirectory "$OldInstallDir" + FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" + ${If} $DataBackupFindHandle != "" + restore_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" + !insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName" + CreateDirectory "$OldInstallDir\$DataBackupFindName" + CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\" + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 restore_subdirs_loop + FindClose $DataBackupFindHandle + ${EndIf} + DetailPrint "Restored folders stored in the install directory." +${EndIf} +; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the +; notice a single time per installer run, covering both loose decks and +; restored subdirectories. +${IfNot} ${Silent} +${AndIf} $LooseDeckNoticeShown != "1" + ${If} $LooseDecksBackupDone == "1" + ${OrIf} $InstallSubdirsBackupDone == "1" + StrCpy $LooseDeckNoticeShown "1" + MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again." + ${EndIf} +${EndIf} +done: +FunctionEnd + +; RelocateRestoredDataToInstDir +; +; componentsPagePre backs up and restores around the old uninstaller before the +; directory page, so $OldInstallDir (registry InstallLocation) is authoritative +; there while $INSTDIR is still the default. When the user then picks a different +; target directory, the restored user data would otherwise stay orphaned in the +; old directory while the new install starts empty. Copy it across when they differ +; (a copy, not a move: the source is user data and is deliberately left in place). +; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup +; comment above); with two different old dirs the first one's data stays restored +; in place instead of moving along. +; StrCmp is case-sensitive while Windows paths are not; a casing-only difference +; triggers a harmless redundant copy onto itself. +Function RelocateRestoredDataToInstDir +StrCmp $DataBackupDir "" done +StrCmp $OldInstallDir "" done +StrCmp $OldInstallDir $INSTDIR done +${If} $DataBackupDone == "1" +${AndIf} ${FileExists} "$OldInstallDir\data" + !insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data" + CreateDirectory "$INSTDIR\data" + CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\" + DetailPrint "Copied restored user data to the new install folder." +${EndIf} +${If} $LooseDecksBackupDone == "1" + ${If} ${FileExists} "$OldInstallDir\*.cod" + ${OrIf} ${FileExists} "$OldInstallDir\*.dec" + ${OrIf} ${FileExists} "$OldInstallDir\*.dek" + ${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" + !insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR" + CreateDirectory "$INSTDIR" + CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\" + DetailPrint "Copied restored deck files to the new install folder." + ${EndIf} +${EndIf} +${If} $InstallSubdirsBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" + FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" + ${If} $DataBackupFindHandle != "" + relocate_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" + !insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName" + CreateDirectory "$INSTDIR\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\" + DetailPrint "Copied restored folder $DataBackupFindName to the new install folder." + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 relocate_subdirs_loop + FindClose $DataBackupFindHandle + ${EndIf} +${EndIf} +done: +FunctionEnd + ; --- Running instance handling --- ; Cockatrice, Oracle and Servatrice must not be running while files are ; replaced or deleted. A still-running process holds locks on its .exe and @@ -1037,11 +1358,17 @@ ${If} $PortableMode = 0 ; instance of the application is still running out of the *old* install ; directory before letting it delete anything. The uninstall string is ; stashed away first, the check works on $R0. + ; $INSTDIR is still the default here (directory page comes later), so the + ; backup/restore dir comes from the registry InstallLocation via $GuardDir, + ; not from $INSTDIR - otherwise a custom-location install is unprotected. StrCpy $OldUninstallString $R0 - !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning + StrCpy $OldInstallDir $GuardDir + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors - ExecWait "$OldUninstallString _?=$INSTDIR" + Call BackupUserDataBeforeUninstall + ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RestoreUserDataAfterUninstall done32: @@ -1061,10 +1388,13 @@ ${If} $PortableMode = 0 uninst64: StrCpy $OldUninstallString $R0 - !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning + StrCpy $OldInstallDir $GuardDir + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors - ExecWait "$OldUninstallString _?=$INSTDIR" + Call BackupUserDataBeforeUninstall + ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RestoreUserDataAfterUninstall done64: ${EndIf} @@ -1188,6 +1518,11 @@ ${EndIf} !insertmacro CockLog "section: writing payload to $INSTDIR" @CPACK_NSIS_FULL_INSTALL@ !insertmacro CockLog "section: payload written" +; The pre-uninstall backup/restore above runs before the directory page, so a +; user picking a different target folder would otherwise leave the restored data +; orphaned in the old directory. Move it across when they differ (no-op on the +; /R path where they are equal). +Call RelocateRestoredDataToInstDir !insertmacro CockLogListing "final" "Qt6*.dll" ${If} $PortableMode = 0 diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 63ce7d1af..e59326214 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -141,6 +141,8 @@ set(cockatrice_SOURCES src/game_graphics/zones/view_zone.cpp src/game_graphics/zones/view_zone_widget.cpp src/game_graphics/board/abstract_graphics_item.cpp + src/interface/at_risk_data_warning.cpp + src/interface/at_risk_data_warning.h src/interface/card_database_update/card_database_update_status_bar.cpp src/interface/card_database_update/card_database_update_status_bar.h src/interface/card_database_update/card_update_progress.cpp diff --git a/cockatrice/src/interface/at_risk_data_warning.cpp b/cockatrice/src/interface/at_risk_data_warning.cpp new file mode 100644 index 000000000..5145d2ebb --- /dev/null +++ b/cockatrice/src/interface/at_risk_data_warning.cpp @@ -0,0 +1,215 @@ +#include "at_risk_data_warning.h" + +#include "../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(AtRiskDataWarningLog, "at_risk_data_warning"); + +static bool isDeckFileSuffix(const QString &suffix) +{ + return suffix == QStringLiteral("cod") || suffix == QStringLiteral("dec") || suffix == QStringLiteral("dek") || + suffix == QStringLiteral("mwdeck"); +} + +static bool isInsideAnyDir(const QString &path, const QStringList &bases) +{ + for (const QString &base : bases) { + if (PathsSettings::isInsideDir(path, base)) { + return true; + } + } + return false; +} + +static bool hasLooseDeckFile(const QStringList &offenders) +{ + for (const QString &offender : offenders) { + if (isDeckFileSuffix(QFileInfo(offender).suffix().toLower())) { + return true; + } + } + return false; +} + +static QString resolveBackupRoot(const QString &appDir) +{ + QString backupRoot = UserDataBackup::defaultBackupRoot(); + if (PathsSettings::isInsideDir(backupRoot, appDir)) { + backupRoot = QDir::temp().absoluteFilePath(QStringLiteral("CockatriceBackups")); + } + return backupRoot; +} + +QStringList AtRiskDataWarning::collectInstalledAtRiskPaths(const QString &appDir) +{ + QStringList offenders = SettingsCache::instance().paths().pathsInsideDir(appDir); + + // Loose decks sitting directly in the program folder are wiped by the old uninstaller + // just like configured paths. Mirror the installer backup set (DeckLoader extensions + // minus *.txt) so a manual run of the old uninstall.exe is covered too. + const QDir appDirHandle(appDir); + const QStringList topLevelFiles = appDirHandle.entryList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QString &fileName : topLevelFiles) { + if (!isDeckFileSuffix(QFileInfo(fileName).suffix().toLower())) { + continue; + } + const QString looseDeck = QDir::cleanPath(appDirHandle.absoluteFilePath(fileName)); + if (!isInsideAnyDir(looseDeck, offenders)) { + offenders.append(looseDeck); + } + } + return offenders; +} + +AtRiskDataWarning::AtRiskBackupOutcome AtRiskDataWarning::backUpAtRiskPaths(const QStringList &sources, + const QString &appDir) +{ + AtRiskBackupOutcome outcome; + const QString backupRoot = resolveBackupRoot(appDir); + + // Throttle: dismissing the dialog without "Don't show again" must not create + // a full duplicate on every launch. A recent session is reused only when it is + // complete and its source manifest covers the current source set; otherwise a + // fresh copy is taken so the dialog never claims an uncovered path is backed up. + // The size estimate runs only when a fresh copy is actually going to happen, + // keeping a synchronous tree walk off the reuse fast path at startup. + // Search all recent sessions for a complete, covering one: a partial failure must not + // pin the newest slot and force a full re-copy on every later launch. + const QString reusableSession = UserDataBackup::findRecentUsableBackupSession(backupRoot, sources, 24); + if (!reusableSession.isEmpty()) { + outcome.sessionDir = reusableSession; + outcome.locations = UserDataBackup::listSessionBackupLocations(reusableSession); + outcome.reused = true; + qCInfo(AtRiskDataWarningLog) << "Reusing recent at-risk user data backup at" << outcome.sessionDir; + } else if (!sources.isEmpty()) { + // The copy runs synchronously on the UI thread at startup, so cap it: a + // pictures folder pointed into the program dir can be gigabytes, and freezing + // the client to duplicate it is worse than asking for a manual copy. + outcome.bytes = UserDataBackup::estimateBackupSize(sources); + if (outcome.bytes >= UserDataBackup::MAX_AUTO_BACKUP_BYTES) { + outcome.skippedDueToSize = true; + qCWarning(AtRiskDataWarningLog) + << "At-risk user data backup skipped: estimated" << outcome.bytes << "bytes exceeds automatic cap"; + } else { + // One timestamped session directory for all sources, so a second boundary + // never splits a single startup check across two backup folders and + // same-named sources cannot overwrite each other. + outcome.complete = + UserDataBackup::backupAtRiskPaths(sources, backupRoot, &outcome.locations, &outcome.sessionDir); + UserDataBackup::pruneOldBackupSessions(backupRoot, 5); + if (outcome.locations.isEmpty()) { + // Fresh copy failed entirely: fall back to the newest covering session so the + // dialog still points at something, flagged incomplete below. + const QString fallbackSession = + UserDataBackup::findRecentCoveringBackupSession(backupRoot, sources, 24); + if (!fallbackSession.isEmpty()) { + outcome.sessionDir = fallbackSession; + outcome.locations = UserDataBackup::listSessionBackupLocations(fallbackSession); + outcome.complete = false; + } + } + } + } + for (const QString &backupLocation : outcome.locations) { + qCInfo(AtRiskDataWarningLog) << "Backed up at-risk user data to" << backupLocation; + } + if (!outcome.complete) { + qCWarning(AtRiskDataWarningLog) << "At-risk user data backup incomplete, session dir:" << outcome.sessionDir; + } + return outcome; +} + +QString AtRiskDataWarning::buildInstalledAtRiskMessage(const QStringList &offenders, + const QStringList &sources, + const AtRiskBackupOutcome &outcome) +{ + const QString backupVerb = outcome.reused ? tr("is already available") : tr("has been created"); + const QString sizeInfo = tr("%1 MB (automatic backups are capped at %2 MB)") + .arg(QString::number(outcome.bytes / 1048576.0, 'f', 1), + QString::number(UserDataBackup::MAX_AUTO_BACKUP_BYTES / 1048576)); + QString message = tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n" + "Cockatrice keeps its own data outside the program directory by default. Files " + "stored inside it can be deleted by an update or by uninstalling Cockatrice. " + "Change them in the Settings dialog - most are on the General page - to keep " + "them safe.") + .arg(offenders.join(QLatin1Char('\n'))); + if (!outcome.locations.isEmpty()) { + message += tr("\n\nA safety backup %1 at:\n%2").arg(backupVerb, outcome.locations.join(QLatin1Char('\n'))); + if (!outcome.complete) { + message += tr("\n\nThe backup may be incomplete; please back up these folders manually as well."); + } + if (hasLooseDeckFile(offenders)) { + message += tr("\n\nDeck files ending in .txt are not backed up - move those manually."); + } + } else if (outcome.skippedDueToSize) { + message += tr("\n\nThe automatic safety backup was skipped because of its size: %1. " + "Please back up these folders manually.") + .arg(sizeInfo); + } else if (sources.isEmpty()) { + message += tr("\n\nNone of these paths exist on disk yet, so there was nothing to back up. " + "Files created there later are still at risk until the paths are moved."); + } else { + message += tr("\n\nThe automatic safety backup failed; please back up these folders manually."); + } + return message; +} + +void AtRiskDataWarning::showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message) +{ + QMessageBox box(QMessageBox::Warning, title, message, QMessageBox::Ok, parent); + box.setTextFormat(Qt::PlainText); + auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); + box.setCheckBox(dontShowAgain); + box.exec(); + if (dontShowAgain->isChecked()) { + SettingsCache::instance().paths().setAppDirWarningAcknowledged(true); + } +} + +void AtRiskDataWarning::warnAboutDataPathsInProgramDir(QWidget *parent) +{ + auto &paths = SettingsCache::instance().paths(); + if (paths.getAppDirWarningAcknowledged()) { + return; + } + + // Portable installs keep their data inside the program directory by design, but they + // never shipped an uninstaller, and portable updates use the installer's payload + // purge, which preserves data/ - so there is nothing destructive to warn about. + if (SettingsCache::instance().getIsPortableBuild()) { + return; + } + + const QString appDir = QCoreApplication::applicationDirPath(); + + // Sources to back up outside the install directory before warning. The uninstallers + // shipped in 3.1.0/3.1.1 delete the whole install tree, so a safety copy is kept + // even though the fixed uninstaller preserves foreign files - the destructive one + // is already on disk on affected machines. + const QStringList offenders = collectInstalledAtRiskPaths(appDir); + if (offenders.isEmpty()) { + return; + } + // Only existing paths can be copied; a configured-but-never-created path has + // nothing to lose yet and must not turn the warning into a bogus "failed". + QStringList sourcesToBackUp; + for (const QString &offender : offenders) { + if (QFileInfo::exists(offender)) { + sourcesToBackUp.append(offender); + } + } + + const AtRiskBackupOutcome outcome = backUpAtRiskPaths(sourcesToBackUp, appDir); + + const QString message = buildInstalledAtRiskMessage(offenders, sourcesToBackUp, outcome); + showAtRiskDataWarning(parent, tr("Data paths inside the program directory"), message); +} diff --git a/cockatrice/src/interface/at_risk_data_warning.h b/cockatrice/src/interface/at_risk_data_warning.h new file mode 100644 index 000000000..22cad86fa --- /dev/null +++ b/cockatrice/src/interface/at_risk_data_warning.h @@ -0,0 +1,50 @@ +#ifndef AT_RISK_DATA_WARNING_H +#define AT_RISK_DATA_WARNING_H + +#include +#include +#include +#include + +class QWidget; + +/** + * @brief Warns when user data lives inside the program directory and backs it up first. + * + * The uninstallers shipped in Cockatrice 3.1.0/3.1.1 delete the whole install + * tree, so data paths configured inside the program directory get a safety + * backup outside the install directory before the warning is shown. + * Portable installs keep their data inside the program directory by design but + * never shipped an uninstaller, so they are skipped. + */ +class AtRiskDataWarning +{ + Q_DECLARE_TR_FUNCTIONS(AtRiskDataWarning) + +public: + struct AtRiskBackupOutcome + { + QStringList locations; ///< backup targets created for this check + QString sessionDir; ///< session directory holding the backup + bool complete = true; ///< false when only a partial/fallback backup exists + bool reused = false; ///< true when a recent session was reused instead of copying + bool skippedDueToSize = false; ///< true when the automatic copy was skipped for size + qint64 bytes = 0; ///< estimated bytes, only set when a fresh copy was attempted + }; + + /** + * @brief Backs up at-risk data paths and warns about them. + * @param parent Parent widget for the warning dialog + */ + static void warnAboutDataPathsInProgramDir(QWidget *parent); + +private: + static QStringList collectInstalledAtRiskPaths(const QString &appDir); + static AtRiskBackupOutcome backUpAtRiskPaths(const QStringList &sources, const QString &appDir); + static QString buildInstalledAtRiskMessage(const QStringList &offenders, + const QStringList &sources, + const AtRiskBackupOutcome &outcome); + static void showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message); +}; + +#endif // AT_RISK_DATA_WARNING_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 57d6e8b15..76092e765 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -37,6 +37,7 @@ #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" #include "abstract_client.h" +#include "at_risk_data_warning.h" #include "card_database_update/card_database_update_status_bar.h" #include "card_database_update/card_update_progress.h" #include "intents/contexts/context_connect_to_server.h" @@ -58,13 +59,12 @@ #include #include #include -#include #include -#include #include #include #include #include +#include #include #include #include @@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent) QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } -void MainWindow::warnAboutDataPathsInProgramDir() -{ - auto &paths = SettingsCache::instance().paths(); - if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) { - return; - } - - const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath()); - if (offenders.isEmpty()) { - return; - } - - QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"), - tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n" - "Cockatrice keeps its own data outside the program directory by default. Files " - "stored inside it can be deleted by an update or by uninstalling Cockatrice. " - "Change them in the Settings dialog - most are on the General page - to keep " - "them safe.") - .arg(offenders.join(QLatin1Char('\n'))), - QMessageBox::Ok, this); - box.setTextFormat(Qt::PlainText); - auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); - box.setCheckBox(dontShowAgain); - box.exec(); - if (dontShowAgain->isChecked()) { - paths.setAppDirWarningAcknowledged(true); - } -} - void MainWindow::startupConfigCheck() { - warnAboutDataPathsInProgramDir(); + AtRiskDataWarning::warnAboutDataPathsInProgramDir(this); const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 2b6ed5633..4b3da716d 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -149,9 +149,6 @@ private: void runFirstRunWizard(bool firstRun = false); - /** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */ - void warnAboutDataPathsInProgramDir(); - inline QString getCardUpdaterBinaryName() { return "oracle"; diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 5e6fa986d..9b21a071f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -13,6 +13,7 @@ set(UTILITY_SOURCES libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/translation_loader.cpp + libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp ) @@ -35,6 +36,7 @@ set(UTILITY_HEADERS libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h libcockatrice/utility/translation_loader.h + libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.h ) diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp new file mode 100644 index 000000000..aa7095545 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp @@ -0,0 +1,405 @@ +#include "user_data_backup.h" + +#include +#include +#include +#include +#include +#include + +namespace UserDataBackup +{ + +namespace +{ + +const QString COMPLETE_MARKER = QStringLiteral("backup.complete"); +const QString INCOMPLETE_MARKER = QStringLiteral("backup.incomplete"); +const QString SOURCES_MANIFEST = QStringLiteral("backup.sources"); + +bool isMarkerFile(const QString &fileName) +{ + return fileName == COMPLETE_MARKER || fileName == INCOMPLETE_MARKER || fileName == SOURCES_MANIFEST; +} + +bool pathsEqual(const QString &a, const QString &b) +{ +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + return QDir::cleanPath(a).compare(QDir::cleanPath(b), Qt::CaseInsensitive) == 0; +#else + return QDir::cleanPath(a) == QDir::cleanPath(b); +#endif +} + +void writeCompletionMarker(const QString &sessionDir, bool complete) +{ + // Best effort: a missing marker only means isBackupSessionComplete() + // reports the session as incomplete. + QFile marker(QDir(sessionDir).absoluteFilePath(complete ? COMPLETE_MARKER : INCOMPLETE_MARKER)); + if (marker.open(QIODevice::WriteOnly | QIODevice::Text)) { + marker.write(complete ? "complete\n" : "incomplete\n"); + } +} + +QString uniqueTargetPath(const QString &sessionDir, const QString &fileName) +{ + QDir dir(sessionDir); + QString candidate = dir.absoluteFilePath(fileName); + if (!QFileInfo::exists(candidate)) { + return candidate; + } + const QFileInfo info(fileName); + const QString base = info.completeBaseName(); + const QString suffix = info.suffix(); + for (int i = 2;; ++i) { + QString numbered = suffix.isEmpty() ? QStringLiteral("%1 (%2)").arg(base).arg(i) + : QStringLiteral("%1 (%2).%3").arg(base).arg(i).arg(suffix); + candidate = dir.absoluteFilePath(numbered); + if (!QFileInfo::exists(candidate)) { + return candidate; + } + } +} + +} // namespace + +bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir) +{ + QDir source(sourceDir); + if (!source.exists()) { + return false; + } + if (!QDir().mkpath(destDir)) { + return false; + } + + bool ok = true; + QDirIterator it(sourceDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + QString entryRel = source.relativeFilePath(it.filePath()); + QString target = QDir(destDir).absoluteFilePath(entryRel); + if (it.fileInfo().isSymLink()) { + // Copy the link itself best-effort without dereferencing it. QDirIterator does not + // follow symlinks here, so a symlink-to-directory must not take the isDir() branch + // below (which would create an empty dir and silently drop the contents). + QDir().mkpath(QFileInfo(target).absolutePath()); + if (QFile::exists(target) && !QFile::remove(target)) { + ok = false; + continue; + } + if (!QFile::copy(it.filePath(), target)) { + ok = false; + } + } else if (it.fileInfo().isDir()) { + if (!QDir().mkpath(target)) { + ok = false; + } + } else { + // Ensure the parent exists even if the iterator yields a file + // before its directory entry. + QDir().mkpath(QFileInfo(target).absolutePath()); + if (QFile::exists(target) && !QFile::remove(target)) { + ok = false; + continue; + } + if (!QFile::copy(it.filePath(), target)) { + ok = false; + } + } + } + return ok; +} + +bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut) +{ + if (!QDir().mkpath(backupRoot)) { + return false; + } + const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-hhmmss-zzz")); + const QDir root(backupRoot); + QString sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-") + timestamp); + for (int i = 1; QFileInfo::exists(sessionDir); ++i) { + sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-%1-%2").arg(timestamp).arg(i)); + } + if (!QDir().mkpath(sessionDir)) { + return false; + } + if (sessionDirOut) { + *sessionDirOut = sessionDir; + } + return true; +} + +bool backupAtRiskPaths(const QStringList &sourcePaths, + const QString &backupRoot, + QStringList *backupLocationsOut, + QString *sessionDirOut) +{ + QStringList existing; + for (const QString &sourcePath : sourcePaths) { + if (QFileInfo::exists(sourcePath)) { + existing.append(sourcePath); + } + } + if (existing.isEmpty()) { + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + + QString sessionDir; + if (!createBackupSessionDir(backupRoot, &sessionDir)) { + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + if (sessionDirOut) { + *sessionDirOut = sessionDir; + } + + bool allOk = true; + QStringList locations; + for (const QString &sourcePath : existing) { + const QFileInfo source(sourcePath); + const QString backupLocation = uniqueTargetPath(sessionDir, source.fileName()); + bool ok; + if (source.isDir()) { + ok = copyDirectoryRecursively(sourcePath, backupLocation); + } else { + ok = QFile::copy(sourcePath, backupLocation); + } + // Report the location whenever a target was created, even for partial + // copies, so callers can point the user at what was salvaged. + if (QFileInfo::exists(backupLocation)) { + locations.append(backupLocation); + } else { + // Total failure with no target created (e.g. QFile::copy failed). + ok = false; + } + allOk = allOk && ok; + } + if (locations.isEmpty()) { + // Nothing salvaged: remove the session again so the empty directory + // neither blocks a retry via the recent-session reuse nor evicts a + // good session in pruneOldBackupSessions(). + QDir(sessionDir).removeRecursively(); + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + writeCompletionMarker(sessionDir, allOk); + // Record which sources this session covers so callers can reuse it only + // when it actually covers the current source set (see sessionCoversSources). + QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST)); + if (manifest.open(QIODevice::WriteOnly | QIODevice::Text)) { + for (const QString &sourcePath : existing) { + manifest.write(QDir::cleanPath(sourcePath).toUtf8() + "\n"); + } + } + if (backupLocationsOut) { + *backupLocationsOut = locations; + } + return allOk; +} + +qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes) +{ + qint64 total = 0; + for (const QString &sourcePath : sourcePaths) { + const QFileInfo source(sourcePath); + if (!source.exists()) { + continue; + } + if (source.isDir()) { + QDirIterator it(sourcePath, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + total += it.fileInfo().size(); + if (total >= capBytes) { + return total; + } + } + } else { + total += source.size(); + if (total >= capBytes) { + return total; + } + } + } + return total; +} + +QString defaultBackupRoot() +{ + QString documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (!documents.isEmpty()) { + return QDir(documents).absoluteFilePath("CockatriceBackups"); + } + QString appData = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + if (!appData.isEmpty()) { + return QDir::cleanPath(QDir(appData).absoluteFilePath("../CockatriceBackups")); + } + return QDir::temp().absoluteFilePath("CockatriceBackups"); +} + +QStringList listBackupSessions(const QString &backupRoot) +{ + QDir root(backupRoot); + if (!root.exists()) { + return {}; + } + const QStringList names = root.entryList(QStringList() << QStringLiteral("CockatriceDataBackup-*"), + QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name | QDir::Reversed); + QStringList sessions; + sessions.reserve(names.size()); + for (const QString &name : names) { + sessions.append(root.absoluteFilePath(name)); + } + return sessions; +} + +QString findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours) +{ + const QStringList sessions = listBackupSessions(backupRoot); + const QDateTime now = QDateTime::currentDateTime(); + for (const QString &session : sessions) { + const QFileInfo info(session); + if (!info.exists()) { + continue; + } + if (info.lastModified().secsTo(now) >= static_cast(maxAgeHours) * 3600) { + continue; + } + if (listSessionBackupLocations(session).isEmpty()) { + continue; + } + if (!sessionCoversSources(session, sourcePaths)) { + continue; + } + return session; + } + return {}; +} + +QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours) +{ + if (sourcePaths.isEmpty()) { + return {}; + } + const QStringList sessions = listBackupSessions(backupRoot); + const QDateTime now = QDateTime::currentDateTime(); + for (const QString &session : sessions) { + const QFileInfo info(session); + if (!info.exists()) { + continue; + } + if (info.lastModified().secsTo(now) >= static_cast(maxAgeHours) * 3600) { + continue; + } + if (listSessionBackupLocations(session).isEmpty()) { + continue; + } + if (!isBackupSessionComplete(session)) { + continue; + } + if (!sessionCoversSources(session, sourcePaths)) { + continue; + } + return session; + } + return {}; +} + +bool isBackupSessionComplete(const QString &sessionDir) +{ + const QDir session(sessionDir); + if (!session.exists()) { + return false; + } + return QFileInfo::exists(session.absoluteFilePath(COMPLETE_MARKER)); +} + +void pruneOldBackupSessions(const QString &backupRoot, int keepCount) +{ + const QStringList sessions = listBackupSessions(backupRoot); + QStringList kept; + kept.reserve(sessions.size()); + for (const QString &session : sessions) { + // Empty sessions are junk from failed attempts: drop them outright so + // they can never evict a good session from the kept set. + if (listSessionBackupLocations(session).isEmpty()) { + QDir(session).removeRecursively(); + continue; + } + if (kept.size() < keepCount) { + kept.append(session); + continue; + } + QDir(session).removeRecursively(); + } +} + +QStringList listSessionBackupLocations(const QString &sessionDir) +{ + const QDir session(sessionDir); + if (!session.exists()) { + return {}; + } + const QStringList entries = session.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden); + QStringList locations; + locations.reserve(entries.size()); + for (const QString &entry : entries) { + if (isMarkerFile(entry)) { + continue; + } + locations.append(session.absoluteFilePath(entry)); + } + return locations; +} + +bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths) +{ + QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST)); + if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { + // No readable manifest (e.g. the write failed): coverage cannot be + // verified, so report not-covered and let the caller take a fresh copy. + return false; + } + QStringList recorded; + while (!manifest.atEnd()) { + const QString line = QString::fromUtf8(manifest.readLine()).trimmed(); + if (!line.isEmpty()) { + recorded.append(line); + } + } + for (const QString &source : sourcePaths) { + bool found = false; + for (const QString &entry : recorded) { + if (pathsEqual(source, entry)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; +} + +} // namespace UserDataBackup diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.h b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h new file mode 100644 index 000000000..4a4dc1d7f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h @@ -0,0 +1,132 @@ +/** + * @file user_data_backup.h + * @ingroup Utility + * @brief Best-effort safety copies of user data that lives inside the install directory. + * + * Background: Windows uninstallers shipped in 3.1.0/3.1.1 ran a blanket + * "RMDir /r $INSTDIR". For a portable install that deletes $INSTDIR\data + * (decks, settings, pictures) along with the payload, and for a normal install + * it deletes anything the user pointed into $INSTDIR. The fixed uninstaller + * preserves foreign files, but the destructive one is already on disk on + * affected machines, so the client keeps a safety copy outside the install + * directory before warning the user. Detection of at-risk paths lives in + * PathsSettings::pathsInsideDir; this helper only copies. + */ + +#ifndef USER_DATA_BACKUP_H +#define USER_DATA_BACKUP_H + +#include +#include + +namespace UserDataBackup +{ + +/** @brief Above this total, the client skips the automatic startup backup and asks for a manual copy. + * Kept small on purpose: the copy runs synchronously on the UI thread at startup. */ +constexpr qint64 MAX_AUTO_BACKUP_BYTES = 200LL * 1024 * 1024; + +/** + * @brief Recursively copies @p sourceDir into @p destDir, creating it as needed. + * @returns true when every file was copied (or there was nothing to copy). + * Symlinks (file or directory) are copied best-effort via QFile::copy without + * dereferencing them; a dangling link fails that entry only. + */ +bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir); + +/** + * @brief Creates a fresh timestamped session directory under @p backupRoot. + * @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups. + * @param sessionDirOut receives the created directory on success. + * @returns true on success. Millisecond timestamps plus a numeric suffix keep rapid + * successive calls (e.g. one per installer phase) from sharing a directory. + */ +bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut); + +/** + * @brief Copies several at-risk paths into a single timestamped session directory. + * @param sourcePaths files or directories holding user data; missing entries are skipped. + * @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups. + * @param backupLocationsOut receives one entry per source that produced a backup target + * (including partial copies). + * @param sessionDirOut optionally receives the shared session directory; set to empty + * when nothing was backed up (all sources missing, or every copy failed outright). + * @returns true when every existing source was copied completely. + * A false return with a non-empty @p backupLocationsOut means a partial backup exists. + * When nothing at all could be salvaged the session directory is removed again, so a + * failed attempt never leaves an empty session behind that would block retries. + * Completeness is recorded with a marker file; see isBackupSessionComplete(). + */ +bool backupAtRiskPaths(const QStringList &sourcePaths, + const QString &backupRoot, + QStringList *backupLocationsOut, + QString *sessionDirOut = nullptr); + +/** + * @brief Estimates the total bytes that backing up @p sourcePaths would copy. + * @param capBytes stop counting once the total exceeds this; the returned value is then + * at least @p capBytes (used to skip the scan early on huge trees). + * Missing entries contribute 0. Best effort only. + */ +qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes = MAX_AUTO_BACKUP_BYTES); + +/** + * @brief Default backup root guaranteed to live outside the install directory. + */ +QString defaultBackupRoot(); + +/** + * @brief Lists existing backup session directories under @p backupRoot, newest first. + */ +QStringList listBackupSessions(const QString &backupRoot); + +/** + * @brief Returns the newest session that is non-empty, complete and covering @p sourcePaths, or empty. + * + * An incomplete newest session (partial failure) or one covering a different + * source set does not block reuse of an older good session. + */ +QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24); + +/** + * @brief Returns the newest non-empty session covering @p sourcePaths, regardless of completeness. + * + * Used as a fallback pointer when a fresh copy fails entirely, so the dialog can still + * point at salvaged content. Completeness is left to the caller to report. + */ +QString +findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24); + +/** + * @brief Reports whether @p sessionDir holds a complete backup. + * @returns true when the session carries the complete marker, false otherwise + * (partial backups and empty/missing sessions). + */ +bool isBackupSessionComplete(const QString &sessionDir); + +/** + * @brief Deletes oldest sessions keeping only the newest @p keepCount ones. + * Empty sessions are junk from failed attempts and are always removed, so they + * can never evict a good session from the kept set. + */ +void pruneOldBackupSessions(const QString &backupRoot, int keepCount = 5); + +/** + * @brief Lists the backup targets inside an existing session directory. + * @returns absolute paths of the entries, for reuse messages without a new copy. + * Completeness marker and source manifest files are internal bookkeeping and are never listed. + */ +QStringList listSessionBackupLocations(const QString &sessionDir); + +/** + * @brief Reports whether @p sessionDir was recorded as covering all of @p sourcePaths. + * @returns true when the session's source manifest lists every entry of @p sourcePaths + * (normalized via QDir::cleanPath, case-insensitive on Windows/macOS). Sessions + * without a readable manifest return false so a changed source set always triggers + * a fresh copy instead of reusing a backup that may not cover it. + */ +bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths); + +} // namespace UserDataBackup + +#endif // USER_DATA_BACKUP_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 784aac3be..ee0fd0698 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,6 +174,7 @@ endfunction() cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) diff --git a/tests/user_data_backup_test.cpp b/tests/user_data_backup_test.cpp new file mode 100644 index 000000000..b74b26fa3 --- /dev/null +++ b/tests/user_data_backup_test.cpp @@ -0,0 +1,581 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include + +namespace +{ + +TEST(UserDataBackup, CopyDirectoryKeepsNestedStructure) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("decks/sub")); + QFile deck(source.absoluteFilePath("decks/sub/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QFile::exists(dest + "/decks/sub/mydeck.cod")); +} + +TEST(UserDataBackup, CopyDirectoryKeepsHiddenFiles) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QFile hidden(QDir(sourceDir.path()).absoluteFilePath(".hidden.ini")); + ASSERT_TRUE(hidden.open(QIODevice::WriteOnly | QIODevice::Text)); + hidden.write("[general]\n"); + hidden.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QFile::exists(dest + "/.hidden.ini")); +} + +TEST(UserDataBackup, CopyDirectoryOverwritesExistingTarget) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + const QString sourceFile = QDir(sourceDir.path()).absoluteFilePath("settings.ini"); + QFile first(sourceFile); + ASSERT_TRUE(first.open(QIODevice::WriteOnly | QIODevice::Text)); + first.write("version=1\n"); + first.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + ASSERT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + + QFile second(sourceFile); + ASSERT_TRUE(second.open(QIODevice::WriteOnly | QIODevice::Text)); + second.write("version=2\n"); + second.close(); + + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + QFile result(dest + "/settings.ini"); + ASSERT_TRUE(result.open(QIODevice::ReadOnly | QIODevice::Text)); + EXPECT_EQ(result.readAll(), QByteArray("version=2\n")); +} + +TEST(UserDataBackup, CopyEmptyDirectorySucceeds) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + ASSERT_TRUE(QDir(sourceDir.path()).mkpath("empty")); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QDir(dest + "/empty").exists()); +} +TEST(UserDataBackup, CopyMissingDirectoryFails) +{ + QTemporaryDir destParent; + ASSERT_TRUE(destParent.isValid()); + EXPECT_FALSE( + UserDataBackup::copyDirectoryRecursively("/nonexistent-cockatrice-path-xyz", destParent.path() + "/copy")); +} + +TEST(UserDataBackup, BackupDirectoryCreatesTimestampedCopy) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + ASSERT_EQ(locations.size(), 1); + EXPECT_TRUE(QFile::exists(locations.first() + "/mydeck.cod")); + EXPECT_TRUE(locations.first().startsWith(QDir(backupRoot.path()).absolutePath())); +} + +TEST(UserDataBackup, BackupSingleFile) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QString cardDb = QDir(sourceDir.path()).absoluteFilePath("cards.xml"); + QFile db(cardDb); + ASSERT_TRUE(db.open(QIODevice::WriteOnly | QIODevice::Text)); + db.write("\n"); + db.close(); + + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({cardDb}, backupRoot.path(), &locations, nullptr)); + ASSERT_EQ(locations.size(), 1); + EXPECT_TRUE(QFile::exists(locations.first())); + EXPECT_TRUE(locations.first().endsWith("cards.xml")); +} + +TEST(UserDataBackup, BackupMissingSourceFails) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations; + QString sessionDir; + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod")}, + backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); +} + +TEST(UserDataBackup, BackupPathsShareSingleSessionDir) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("decks")); + ASSERT_TRUE(source.mkpath("settings")); + QFile deck(source.absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + QFile settings(source.absoluteFilePath("settings/settings.ini")); + ASSERT_TRUE(settings.open(QIODevice::WriteOnly | QIODevice::Text)); + settings.write("[general]\n"); + settings.close(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE( + UserDataBackup::backupAtRiskPaths({source.absoluteFilePath("decks"), source.absoluteFilePath("settings")}, + backupRoot.path(), &locations, &sessionDir)); + ASSERT_EQ(locations.size(), 2); + // Both backups live in the same timestamped session, even across a second boundary. + for (const QString &location : locations) { + EXPECT_TRUE(location.startsWith(QDir(sessionDir).absolutePath())); + } + EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"))); + EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("settings/settings.ini"))); +} + +TEST(UserDataBackup, BackupPathsWithSameNameDoNotCollide) +{ + QTemporaryDir firstDir; + QTemporaryDir secondDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(firstDir.isValid()); + ASSERT_TRUE(secondDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + for (const QString &dir : {firstDir.path(), secondDir.path()}) { + QFile deck(QDir(dir).absoluteFilePath("cards.xml")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + } + + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths( + {QDir(firstDir.path()).absoluteFilePath("cards.xml"), QDir(secondDir.path()).absoluteFilePath("cards.xml")}, + backupRoot.path(), &locations, nullptr)); + ASSERT_EQ(locations.size(), 2); + EXPECT_NE(locations.at(0), locations.at(1)); + EXPECT_TRUE(QFile::exists(locations.at(0))); + EXPECT_TRUE(QFile::exists(locations.at(1))); +} + +TEST(UserDataBackup, RapidBackupsGetUniqueSessionDirs) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QString firstSession; + QString secondSession; + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession)); + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession)); + EXPECT_NE(firstSession, secondSession); +} + +TEST(UserDataBackup, DefaultBackupRootIsUsable) +{ + QString root = UserDataBackup::defaultBackupRoot(); + EXPECT_FALSE(root.isEmpty()); + EXPECT_TRUE(QDir(root).isAbsolute()); +} + +TEST(UserDataBackup, DefaultBackupRootHasNoDotDotSegments) +{ + QString root = UserDataBackup::defaultBackupRoot(); + EXPECT_FALSE(root.contains(QStringLiteral(".."))); + EXPECT_EQ(root, QDir::cleanPath(root)); +} + +TEST(UserDataBackup, ListSessionsFindsRecentAndPrunesOld) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString firstSession; + QString secondSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession)); + + const QStringList sessions = UserDataBackup::listBackupSessions(backupRoot.path()); + ASSERT_EQ(sessions.size(), 2); + // Newest first + EXPECT_EQ(sessions.first(), secondSession); + + const QString recent = UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24); + EXPECT_EQ(recent, secondSession); + + const QStringList reusedLocations = UserDataBackup::listSessionBackupLocations(recent); + EXPECT_FALSE(reusedLocations.isEmpty()); + for (const QString &location : reusedLocations) { + EXPECT_TRUE(location.startsWith(QDir(recent).absolutePath())); + EXPECT_TRUE(QFile::exists(location)); + } + + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 1); + const QStringList pruned = UserDataBackup::listBackupSessions(backupRoot.path()); + ASSERT_EQ(pruned.size(), 1); + EXPECT_EQ(pruned.first(), secondSession); +} + +TEST(UserDataBackup, FindRecentReturnsEmptyWhenNone) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(backupRoot.path() + "/missing").isEmpty()); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(backupRoot.path() + "/missing")); + // Pruning an empty root must not fail + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5); +} + +TEST(UserDataBackup, AllMissingSourcesCreateNoSession) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations; + QString sessionDir; + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({backupRoot.path() + "/nope1", backupRoot.path() + "/nope2"}, + backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); + // No empty session left behind to block a retry or evict good sessions + EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty()); +} + +TEST(UserDataBackup, EmptySessionsAreSkippedAndPruned) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString emptySession; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &emptySession)); + // An empty session from a failed attempt must never block a retry + EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(emptySession).isEmpty()); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(emptySession)); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5); + EXPECT_FALSE(QDir(emptySession).exists()); +} + +TEST(UserDataBackup, SuccessfulBackupIsMarkedComplete) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // Marker files are bookkeeping and must not leak into the user-facing locations + const QStringList listed = UserDataBackup::listSessionBackupLocations(sessionDir); + EXPECT_FALSE(listed.isEmpty()); + for (const QString &location : listed) { + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.complete"))); + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.incomplete"))); + } +} + +TEST(UserDataBackup, IncompleteMarkerIsNotUsableButRemainsCovering) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + ASSERT_TRUE(QFile::remove(QDir(sessionDir).absoluteFilePath("backup.complete"))); + QFile marker(QDir(sessionDir).absoluteFilePath("backup.incomplete")); + ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text)); + marker.write("incomplete\n"); + marker.close(); + + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // Incomplete sessions are skipped by the usable search but still found by the + // covering search, so a failed copy falls back to the salvaged content. + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24).isEmpty()); + EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 24), sessionDir); +} + +TEST(UserDataBackup, SessionWithoutMarkerCountsAsIncomplete) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString sessionDir; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir)); + ASSERT_TRUE(QDir(sessionDir).mkpath("decks")); + QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // No source manifest either, so neither search reuses it. + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); +} + +TEST(UserDataBackup, FindRecentIgnoresExpiredSessions) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty()); +} + +TEST(UserDataBackup, EstimateBackupSize) +{ + QTemporaryDir sourceDir; + ASSERT_TRUE(sourceDir.isValid()); + const QString missing = QDir(sourceDir.path()).absoluteFilePath("missing"); + EXPECT_EQ(UserDataBackup::estimateBackupSize({missing}), 0); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("123456"); + deck.close(); + EXPECT_EQ(UserDataBackup::estimateBackupSize({sourceDir.path(), missing}), 6); + // Early-out: once over the cap the exact total no longer matters + EXPECT_GE(UserDataBackup::estimateBackupSize({sourceDir.path()}, 1), 1); +} + +TEST(UserDataBackup, SessionCoversSourcesRequiresManifestMatch) +{ + QTemporaryDir sourceA; + QTemporaryDir sourceB; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceA.isValid()); + ASSERT_TRUE(sourceB.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod")); + ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text)); + fa.write("a"); + fa.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path()})); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path(), sourceB.path()})); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceB.path()})); +} + +TEST(UserDataBackup, SessionWithoutManifestIsNotCovering) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString sessionDir; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir)); + ASSERT_TRUE(QDir(sessionDir).mkpath("decks")); + QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {QDir(sessionDir).absoluteFilePath("decks")})); +} + +TEST(UserDataBackup, ManifestIsHiddenFromSessionLocations) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("x"); + deck.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + for (const QString &location : UserDataBackup::listSessionBackupLocations(sessionDir)) { + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.sources"))); + } +} + +TEST(UserDataBackup, FindRecentUsableSkipsIncompleteNewest) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString oldSession; + QString newSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &oldSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &newSession)); + ASSERT_NE(oldSession, newSession); + + // Newest session becomes incomplete: usable search must fall back to the older complete one. + ASSERT_TRUE(QFile::remove(QDir(newSession).absoluteFilePath("backup.complete"))); + QFile marker(QDir(newSession).absoluteFilePath("backup.incomplete")); + ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text)); + marker.write("incomplete\n"); + marker.close(); + + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}), oldSession); + // Covering search still finds the newest session even though it is incomplete. + EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}), newSession); +} + +TEST(UserDataBackup, FindRecentUsableRequiresCoveringSet) +{ + QTemporaryDir sourceA; + QTemporaryDir sourceB; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceA.isValid()); + ASSERT_TRUE(sourceB.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod")); + ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text)); + fa.write("a"); + fa.close(); + QFile fb(QDir(sourceB.path()).absoluteFilePath("b.cod")); + ASSERT_TRUE(fb.open(QIODevice::WriteOnly | QIODevice::Text)); + fb.write("b"); + fb.close(); + + QStringList locations; + QString oldSession; + QString newSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &oldSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceB.path()}, backupRoot.path(), &locations, &newSession)); + + // Newest covers B only, so a query for A must reuse the older session. + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path()}), oldSession); + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceB.path()}), newSession); + EXPECT_TRUE( + UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path(), sourceB.path()}).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {}).isEmpty()); +} + +TEST(UserDataBackup, CopyDirectoryDoesNotSilentlyEmptyDirSymlink) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("real")); + QFile inner(source.absoluteFilePath("real/card.cod")); + ASSERT_TRUE(inner.open(QIODevice::WriteOnly | QIODevice::Text)); + inner.write("x"); + inner.close(); + if (!QFile::link(source.absoluteFilePath("real"), source.absoluteFilePath("link"))) { + GTEST_SKIP() << "Symlinks not permitted in this environment"; + } + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + const bool ok = UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest); + const QFileInfo linkCopy(QDir(dest).absoluteFilePath("link")); + // Old behavior created an empty plain dir and reported success, silently dropping contents. + // New behavior copies the link best-effort; if that fails it must report failure. + if (ok) { + EXPECT_TRUE(linkCopy.exists() || linkCopy.isSymLink()); + if (linkCopy.isDir() && !linkCopy.isSymLink()) { + EXPECT_FALSE(QDir(linkCopy.absoluteFilePath()).isEmpty()); + } + } else { + EXPECT_TRUE(linkCopy.isSymLink() || !linkCopy.exists() || !QDir(linkCopy.absoluteFilePath()).isEmpty()); + } +} + +TEST(UserDataBackup, PluralBackupClearsOutParamsOnTotalFailure) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations = {QStringLiteral("stale")}; + QString sessionDir = QStringLiteral("stale"); + const QString missing = QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod"); + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({missing}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From be38e8d429d6792dbf22165329c27ede0ec46533 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:37 +0200 Subject: [PATCH 039/247] [Protocol] Add room chat channel extensions and catalog payload (#7475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Protocol] Add room chat channel extensions and catalog payload * [Protocol] Add per-channel write level and banner to room channel catalog Adds two optional fields to ServerInfo_RoomChannel: - write_access_level: minimum privilege level required to send to the channel. When unset, writing requires the same level as reading (access_level), preserving the previous single-level behavior. This allows read-mostly channels such as a developer news feed: readable by everyone, writable only by developers. - banner_message: static notice shown at the top of the channel, analogous to the room join message on Main. Empty means none. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 ++ .../protocol/pb/event_room_channel_say.proto | 12 ++++++++++ .../protocol/pb/room_event.proto | 1 + .../protocol/pb/serverinfo_chat_message.proto | 1 + .../protocol/pb/serverinfo_room.proto | 2 ++ .../protocol/pb/serverinfo_room_channel.proto | 23 +++++++++++++++++++ 6 files changed, 41 insertions(+) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 4e4f0e190..bd0b5bda2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -117,6 +117,7 @@ set(PROTO_FILES event_reveal_cards.proto event_reverse_turn.proto event_roll_die.proto + event_room_channel_say.proto event_room_say.proto event_server_complete_list.proto event_server_identification.proto @@ -201,6 +202,7 @@ set(PROTO_FILES serverinfo_replay_match.proto serverinfo_report.proto serverinfo_room.proto + serverinfo_room_channel.proto serverinfo_user.proto serverinfo_user_alt.proto serverinfo_user_session.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto new file mode 100644 index 000000000..c8de1a799 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "room_event.proto"; + +message Event_RoomChannelSay { + extend RoomEvent { + optional Event_RoomChannelSay ext = 1005; // next free RoomEvent extension + } + optional string channel_id = 1; + optional string name = 2; + optional string message = 3; + optional uint64 time_of = 4; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto index 9fa70e7e1..ec6c4f636 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto @@ -6,6 +6,7 @@ message RoomEvent { ROOM_SAY = 1002; LIST_GAMES = 1003; REMOVE_MESSAGES = 1004; + ROOM_CHANNEL_SAY = 1005; } optional sint32 room_id = 1; extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto index d47ec4fc7..b6d35a32d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto @@ -13,4 +13,5 @@ message ServerInfo_ChatMessage { optional string target_type = 6; // target type (room,game,chat) optional string target_id = 7; // id of target optional string target_name = 8; // name of target + optional string channel = 9; // channel id, for room_channel targets } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto index c1c2e363b..8e99de48f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto @@ -2,6 +2,7 @@ syntax = "proto2"; import "serverinfo_game.proto"; import "serverinfo_user.proto"; import "serverinfo_gametype.proto"; +import "serverinfo_room_channel.proto"; message ServerInfo_Room { optional sint32 room_id = 1; @@ -15,4 +16,5 @@ message ServerInfo_Room { repeated ServerInfo_GameType gametype_list = 9; optional string permissionlevel = 10; optional string privilegelevel = 11; + repeated ServerInfo_RoomChannel channel_list = 12; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto new file mode 100644 index 000000000..d38b1f651 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto @@ -0,0 +1,23 @@ +syntax = "proto2"; + +message ServerInfo_RoomChannel { + enum AccessLevel { + // Minimum privilege level, evaluated against the privilege ladder + // Public < Registered < Judge < Moderator < Admin < Developer. + Public = 0; + Registered = 1; + Judge = 2; + Moderator = 3; + Admin = 4; + Developer = 5; + } + optional string id = 1; // stable ASCII id, e.g. "deck-discussion" + optional string display_name = 2; // server-provided, localizable only server-side + optional AccessLevel access_level = 3 [default = Public]; // minimum privilege level required to read the channel + // Minimum privilege level required to write to the channel. When unset, writing requires the same level as + // reading. This allows read-mostly channels such as a developer news feed: readable by everyone, writable + // only by developers. + optional AccessLevel write_access_level = 4; + // Static banner shown at the top of the channel, analogous to the room join message on Main. Empty = none. + optional string banner_message = 5; +} From 17d2e8d86ec9e05afcd9d5567fcbea8e5cf26cc9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:38 +0200 Subject: [PATCH 040/247] [Server] Add room chat channels with per-channel fan-out and history (#7476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Add room chat channels with per-channel fan-out and history * Fixup: redact all channel histories on message removal removeSaidMessages only redacted the main history, so banned/warned content stayed visible to late joiners on every other channel. Drop the unused channelId parameter and redact up to amount messages per channel across the whole channelChatHistory map. * [Server] Validate room channel ids and fix catalog schema per review - servatrice.sql: expand rooms_channels access_level enum to full set to match 0037->0038 migration - servatrice.cpp: skip empty, reserved (__main__) and duplicate channel ids when loading from ini and DB with qWarning - server_protocolhandler.cpp: clarify catalog replay comment (filtering happens in moderator stack) - server_room: drop duplicate channelIndex, linear search preserves catalog order * [Server] Fix server_room.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../network/server/remote/server.cpp | 2 +- .../server/remote/server_protocolhandler.cpp | 48 +++++++++- .../server/remote/server_protocolhandler.h | 3 + .../network/server/remote/server_room.cpp | 84 +++++++++++++---- .../network/server/remote/server_room.h | 27 ++++-- .../protocol/pb/room_commands.proto | 9 ++ .../migrations/servatrice_0037_to_0038.sql | 21 +++++ servatrice/servatrice.ini.example | 16 ++++ servatrice/servatrice.sql | 12 ++- servatrice/src/servatrice.cpp | 91 ++++++++++++++++++- .../src/servatrice_database_interface.h | 2 +- 11 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 servatrice/migrations/servatrice_0037_to_0038.sql diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 73b13e23e..d0fb67446 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -451,7 +451,7 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString qDebug() << "externalRoomSay: room id=" << roomId << "not found"; return; } - room->say(userName, message, false); + room->say(userName, message, QString(), false); getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom, room->getId(), room->getName()); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index fbca9589a..0a91bbe77 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -13,6 +13,7 @@ #include "libcockatrice/protocol/pb/server_message.pb.h" #include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h" #include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_room_channel.pb.h" #include "libcockatrice/protocol/pb/session_commands.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" #include "server_database_interface.h" @@ -40,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -252,6 +254,9 @@ Response::ResponseCode Server_ProtocolHandler::processRoomCommandContainer(const case RoomCommand::ROOM_SAY: resp = cmdRoomSay(sc.GetExtension(Command_RoomSay::ext), room, rc); break; + case RoomCommand::ROOM_CHANNEL_SAY: + resp = cmdRoomChannelSay(sc.GetExtension(Command_RoomChannelSay::ext), room, rc); + break; case RoomCommand::CREATE_GAME: resp = cmdCreateGame(sc.GetExtension(Command_CreateGame::ext), room, rc); break; @@ -856,10 +861,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rooms.insert(room->getId(), room); QReadLocker chatHistoryLocker(&room->historyLock); - QList chatHistory = room->getChatHistory(); - ServerInfo_ChatMessage chatMessage; - for (int i = 0; i < chatHistory.size(); ++i) { - chatMessage = chatHistory.at(i); + const QList mainChatHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY); + for (const ServerInfo_ChatMessage &chatMessage : mainChatHistory) { Event_RoomSay roomChatHistory; roomChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message()); roomChatHistory.set_message_type(Event_RoomSay::ChatHistory); @@ -868,6 +871,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory)); } + // Replay the history of every catalog channel. Privilege filtering for + // restricted channels is applied on top of this in the moderator stack. + for (const ServerInfo_RoomChannel &channel : room->getChannels()) { + const QList channelHistory = room->getChatHistory(QString::fromStdString(channel.id())); + for (const ServerInfo_ChatMessage &chatMessage : channelHistory) { + Event_RoomChannelSay channelChatHistory; + channelChatHistory.set_channel_id(channel.id()); + channelChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message()); + channelChatHistory.set_time_of( + QDateTime::fromString(QString::fromStdString(chatMessage.time())).toMSecsSinceEpoch()); + rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(channelChatHistory)); + } + } + Event_RoomSay joinMessageEvent; joinMessageEvent.set_message(room->getJoinMessage().toStdString()); joinMessageEvent.set_message_type(Event_RoomSay::Welcome); @@ -959,6 +976,29 @@ Server_ProtocolHandler::cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room return Response::RespOk; } +Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_RoomChannelSay &cmd, + Server_Room *room, + ResponseContainer & /*rc*/) +{ + const QString channelId = QString::fromStdString(cmd.channel_id()); + const ServerInfo_RoomChannel *channel = room->findChannel(channelId); + if (channel == nullptr) { + // The client only ever offers catalog ids; a crafted or stale id is rejected. + return Response::RespInvalidCommand; + } + + if (!addSaidMessageSize(static_cast(cmd.message().size()))) { + return Response::RespChatFlood; + } + QString msg = QString::fromStdString(cmd.message()); + + msg.replace(QChar('\n'), QChar(' ')); + + room->say(QString::fromStdString(userInfo->name()), msg, channelId); + + return Response::RespOk; +} + Response::ResponseCode Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc) { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index a12706757..65dab99ed 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -30,6 +30,7 @@ class Command_ListRooms; class Command_JoinRoom; class Command_LeaveRoom; class Command_RoomSay; +class Command_RoomChannelSay; class Command_CreateGame; class Command_JoinGame; @@ -66,6 +67,8 @@ private: Response::ResponseCode cmdListUsers(const Command_ListUsers &cmd, ResponseContainer &rc); Response::ResponseCode cmdLeaveRoom(const Command_LeaveRoom &cmd, Server_Room *room, ResponseContainer &rc); Response::ResponseCode cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room, ResponseContainer &rc); + Response::ResponseCode + cmdRoomChannelSay(const Command_RoomChannelSay &cmd, Server_Room *room, ResponseContainer &rc); Response::ResponseCode cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc); Response::ResponseCode cmdJoinGame(const Command_JoinGame &cmd, Server_Room *room, ResponseContainer &rc); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 614de8d63..7a8c59536 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -26,16 +26,20 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include #include #include +const QString Server_Room::MAIN_CHANNEL_KEY = QStringLiteral("__main__"); + Server_Room::Server_Room(int _id, int _chatHistorySize, const QString &_name, @@ -45,10 +49,11 @@ Server_Room::Server_Room(int _id, bool _autoJoin, const QString &_joinMessage, const QStringList &_gameTypes, - Server *parent) + Server *parent, + const QList &_channels) : QObject(parent), id(_id), chatHistorySize(_chatHistorySize), name(_name), description(_description), permissionLevel(_permissionLevel), privilegeLevel(_privilegeLevel), autoJoin(_autoJoin), - joinMessage(_joinMessage), gameTypes(_gameTypes), gamesLock(QReadWriteLock::Recursive) + joinMessage(_joinMessage), gameTypes(_gameTypes), channels(_channels), gamesLock(QReadWriteLock::Recursive) { connect( this, &Server_Room::gameListChanged, this, [this](auto gameInfo) { broadcastGameListUpdate(gameInfo); }, @@ -152,9 +157,33 @@ Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, } } + for (const ServerInfo_RoomChannel &channel : channels) { + result.add_channel_list()->CopyFrom(channel); + } + return result; } +bool Server_Room::isMainChannel(const QString &channelId) +{ + return channelId.isEmpty() || channelId == MAIN_CHANNEL_KEY; +} + +const ServerInfo_RoomChannel *Server_Room::findChannel(const QString &channelId) const +{ + for (const ServerInfo_RoomChannel &channel : channels) { + if (QString::fromStdString(channel.id()) == channelId) { + return &channel; + } + } + return nullptr; +} + +QList Server_Room::getChatHistory(const QString &channelId) const +{ + return channelChatHistory.value(isMainChannel(channelId) ? MAIN_CHANNEL_KEY : channelId); +} + RoomEvent *Server_Room::prepareRoomEvent(const ::google::protobuf::Message &roomEvent) { auto *event = new RoomEvent; @@ -310,12 +339,22 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam return result; } -void Server_Room::say(const QString &userName, const QString &userMessage, bool sendToIsl) +void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl) { - Event_RoomSay event; - event.set_name(userName.toStdString()); - event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + const bool isMain = isMainChannel(channelId); + + if (isMain) { + Event_RoomSay event; + event.set_name(userName.toStdString()); + event.set_message(userMessage.toStdString()); + sendRoomEvent(prepareRoomEvent(event), sendToIsl); + } else { + Event_RoomChannelSay event; + event.set_channel_id(channelId.toStdString()); + event.set_name(userName.toStdString()); + event.set_message(userMessage.toStdString()); + sendRoomEvent(prepareRoomEvent(event), sendToIsl); + } if (chatHistorySize != 0) { ServerInfo_ChatMessage chatMessage; @@ -324,13 +363,18 @@ void Server_Room::say(const QString &userName, const QString &userMessage, bool chatMessage.set_time(dateTimeString.toStdString()); chatMessage.set_sender_name(userName.toStdString()); chatMessage.set_message(userMessage.simplified().toStdString()); - - historyLock.lockForWrite(); - if (chatHistory.size() >= chatHistorySize) { - chatHistory.removeAt(0); + if (!isMain) { + chatMessage.set_channel(channelId.toStdString()); } - chatHistory.push_back(std::move(chatMessage)); + const QString storageKey = isMain ? MAIN_CHANNEL_KEY : channelId; + historyLock.lockForWrite(); + QList &history = channelChatHistory[storageKey]; + if (history.size() >= chatHistorySize) { + history.removeAt(0); + } + + history.push_back(std::move(chatMessage)); historyLock.unlock(); } } @@ -344,13 +388,17 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s sendRoomEvent(prepareRoomEvent(event), sendToIsl); if (chatHistorySize != 0) { - int removed = 0; historyLock.lockForWrite(); - // redact [amount] of the most recent messages from this user from history - for (auto message = chatHistory.rbegin(); message != chatHistory.rend() && removed != amount; ++message) { - if (message->sender_name() == stdStringUserName) { - message->clear_message(); - ++removed; + // redact [amount] of the most recent messages from this user from every channel history, + // so removed content does not resurface for late joiners on any stream + for (auto historyIt = channelChatHistory.begin(); historyIt != channelChatHistory.end(); ++historyIt) { + QList &history = historyIt.value(); + int removed = 0; + for (auto message = history.rbegin(); message != history.rend() && removed != amount; ++message) { + if (message->sender_name() == stdStringUserName) { + message->clear_message(); + ++removed; + } } } historyLock.unlock(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h index 5933894d5..e9227862f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -9,6 +9,7 @@ #include #include #include +#include #include class Server_ProtocolHandler; @@ -47,15 +48,18 @@ private: bool autoJoin; QString joinMessage; QStringList gameTypes; + QList channels; QMap games; QMap externalGames; QMap users; QMap externalUsers; - QList chatHistory; + QMap> channelChatHistory; private slots: void broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool sendToIsl = true); public: + static const QString MAIN_CHANNEL_KEY; + mutable QReadWriteLock usersLock; mutable QReadWriteLock gamesLock; mutable QReadWriteLock historyLock; @@ -68,7 +72,8 @@ public: bool _autoJoin, const QString &_joinMessage, const QStringList &_gameTypes, - Server *parent); + Server *parent, + const QList &_channels = {}); ~Server_Room() override; int getId() const { @@ -103,6 +108,17 @@ public: { return gameTypes; } + const QList &getChannels() const + { + return channels; + } + /** + * @brief Looks up a catalog channel by its stable id. + * @return The matching channel, or nullptr when the id is not in the catalog. + */ + const ServerInfo_RoomChannel *findChannel(const QString &channelId) const; + /** @brief True when @p channelId refers to the implicit Main channel. */ + static bool isMainChannel(const QString &channelId); const QMap &getGames() const { return games; @@ -116,10 +132,7 @@ public: getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const; int getGamesCreatedByUser(const QString &name) const; QList getGamesOfUser(const QString &name) const; - QList &getChatHistory() - { - return chatHistory; - } + QList getChatHistory(const QString &channelId) const; void addClient(Server_ProtocolHandler *client); void removeClient(Server_ProtocolHandler *client); @@ -136,7 +149,7 @@ public: ResponseContainer &rc, Server_AbstractUserInterface *userInterface); - void say(const QString &userName, const QString &s, bool sendToIsl = true); + void say(const QString &userName, const QString &s, const QString &channelId = QString(), bool sendToIsl = true); void removeSaidMessages(const QString &userName, int amount, bool sendToIsl = true); void addGame(Server_Game *game); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index a8c90ec6c..9d9c6d277 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -5,6 +5,7 @@ message RoomCommand { ROOM_SAY = 1001; CREATE_GAME = 1002; JOIN_GAME = 1003; + ROOM_CHANNEL_SAY = 1004; } extensions 100 to max; } @@ -22,6 +23,14 @@ message Command_RoomSay { optional string message = 1; } +message Command_RoomChannelSay { + extend RoomCommand { + optional Command_RoomChannelSay ext = 1004; + } + optional string channel_id = 1; + optional string message = 2; +} + // Create a new game in the room message Command_CreateGame { extend RoomCommand { diff --git a/servatrice/migrations/servatrice_0037_to_0038.sql b/servatrice/migrations/servatrice_0037_to_0038.sql new file mode 100644 index 000000000..6b8f155ab --- /dev/null +++ b/servatrice/migrations/servatrice_0037_to_0038.sql @@ -0,0 +1,21 @@ +-- Servatrice db migration from version 37 to version 38 + +-- Room chat channels. +-- +-- A room now offers a fixed catalog of named message streams in addition to the +-- implicit Main channel. Public channels are broadcast to every room member; +-- restricted channels are only delivered to (and sendable by) members whose +-- privilege level meets the channel's access level. + +-- 1. Catalog table, mirroring the config-driven channels of no-DB servers. +CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( + `id_room` int(7) unsigned NOT NULL, + `id_server` tinyint(3) NOT NULL DEFAULT 1, + `channel_id` varchar(50) NOT NULL, + `display_name` varchar(50) NOT NULL, + `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + PRIMARY KEY (`id_room`, `id_server`, `channel_id`), + FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +UPDATE cockatrice_schema_version SET version=38 WHERE version=37; \ No newline at end of file diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index f802f0244..e45b38804 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -332,6 +332,22 @@ roomlist\1\game_types\1\name="GameType1" roomlist\1\game_types\2\name="GameType2" roomlist\1\game_types\3\name="GameType3" +; Optional chat channels for room number 1, in addition to the implicit Main +; channel every room has. Each channel needs a stable ASCII id, a display name +; (server data, like the room name) and an access level. Supported levels, from +; least to most restrictive, are: public, registered, judge, moderator, admin +; and developer. A channel is readable and writable by every member whose own +; privilege level is at least the channel's level -- so a moderator channel is +; also reachable by admins and developers, and an admin channel by developers. +; Public channels are visible to every room member. +roomlist\1\channels\size=2 +roomlist\1\channels\1\id=looking-for-players +roomlist\1\channels\1\displayname="Looking for players" +roomlist\1\channels\1\access=public +roomlist\1\channels\2\id=moderators +roomlist\1\channels\2\displayname="Moderators" +roomlist\1\channels\2\access=moderator + [game] diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 4fbc1d8bd..c83f5bb06 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(37); +INSERT INTO cockatrice_schema_version VALUES(38); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -158,6 +158,16 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_gametypes` ( FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( + `id_room` int(7) unsigned NOT NULL, + `id_server` tinyint(3) NOT NULL DEFAULT 1, + `channel_id` varchar(50) NOT NULL, + `display_name` varchar(50) NOT NULL, + `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + PRIMARY KEY (`id_room`, `id_server`, `channel_id`), + FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + -- games CREATE TABLE IF NOT EXISTS `cockatrice_games` ( `room_name` varchar(255) NOT NULL, diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index a832c1652..9dd5a987c 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -249,6 +251,34 @@ Servatrice::~Servatrice() prepareDestroy(); } +/** + * @brief Maps a configured channel access level (database column or ini value) + * onto the ServerInfo_RoomChannel privilege ladder. + * + * Unknown keywords fall back to Public so a typo never hides a channel that was + * meant to be broadly readable. + */ +static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QString &key) +{ + const QString level = key.trimmed().toLower(); + if (level == "developer") { + return ServerInfo_RoomChannel::Developer; + } + if (level == "admin") { + return ServerInfo_RoomChannel::Admin; + } + if (level == "moderator") { + return ServerInfo_RoomChannel::Moderator; + } + if (level == "judge") { + return ServerInfo_RoomChannel::Judge; + } + if (level == "registered") { + return ServerInfo_RoomChannel::Registered; + } + return ServerInfo_RoomChannel::Public; +} + bool Servatrice::initServer() { @@ -360,10 +390,41 @@ bool Servatrice::initServer() while (query2->next()) { gameTypes.append(query2->value(0).toString()); } + + QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery( + "select channel_id, display_name, access_level from {prefix}_rooms_channels where id_room = :id_room " + "AND id_server = :id_server order by channel_id asc"); + query3->bindValue(":id_server", serverId); + query3->bindValue(":id_room", query->value(0).toInt()); + servatriceDatabaseInterface->execSqlQuery(query3); + QList channels; + QSet seenChannelIds; + while (query3->next()) { + const QString channelId = query3->value(0).toString(); + if (channelId.isEmpty()) { + qWarning() << "Skipping room channel with empty id"; + continue; + } + if (Server_Room::isMainChannel(channelId)) { + qWarning() << "Skipping room channel with reserved id" << channelId; + continue; + } + if (seenChannelIds.contains(channelId)) { + qWarning() << "Skipping duplicate room channel id" << channelId; + continue; + } + seenChannelIds.insert(channelId); + ServerInfo_RoomChannel channel; + channel.set_id(channelId.toStdString()); + channel.set_display_name(query3->value(1).toString().toStdString()); + channel.set_access_level(roomChannelAccessFromKey(query3->value(2).toString())); + channels.append(channel); + } + addRoom(new Server_Room(query->value(0).toInt(), query->value(7).toInt(), query->value(1).toString(), query->value(2).toString(), query->value(3).toString().toLower(), query->value(4).toString().toLower(), static_cast(query->value(5).toInt()), - query->value(6).toString(), gameTypes, this)); + query->value(6).toString(), gameTypes, this, channels)); } } else { int size = settingsCache->beginReadArray("rooms/roomlist"); @@ -376,12 +437,38 @@ bool Servatrice::initServer() gameTypes.append(settingsCache->value("name").toString()); } settingsCache->endArray(); + QList channels; + QSet seenChannelIds; + int size3 = settingsCache->beginReadArray("channels"); + for (int k = 0; k < size3; ++k) { + settingsCache->setArrayIndex(k); + const QString channelId = settingsCache->value("id").toString(); + if (channelId.isEmpty()) { + qWarning() << "Skipping room channel with empty id"; + continue; + } + if (Server_Room::isMainChannel(channelId)) { + qWarning() << "Skipping room channel with reserved id" << channelId; + continue; + } + if (seenChannelIds.contains(channelId)) { + qWarning() << "Skipping duplicate room channel id" << channelId; + continue; + } + seenChannelIds.insert(channelId); + ServerInfo_RoomChannel channel; + channel.set_id(channelId.toStdString()); + channel.set_display_name(settingsCache->value("displayname").toString().toStdString()); + channel.set_access_level(roomChannelAccessFromKey(settingsCache->value("access").toString())); + channels.append(channel); + } + settingsCache->endArray(); Server_Room *newRoom = new Server_Room( i, settingsCache->value("chathistorysize").toInt(), settingsCache->value("name").toString(), settingsCache->value("description").toString(), settingsCache->value("permissionlevel").toString().toLower(), settingsCache->value("privilegelevel").toString().toLower(), settingsCache->value("autojoin").toBool(), - settingsCache->value("joinmessage").toString(), gameTypes, this); + settingsCache->value("joinmessage").toString(), gameTypes, this, channels); addRoom(newRoom); } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 411360fa7..0bae67e6c 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -21,7 +21,7 @@ class QSqlQuery; -#define DATABASE_SCHEMA_VERSION 37 +#define DATABASE_SCHEMA_VERSION 38 class Servatrice; From 05dcde69dbd9d868abaa9196512da1c1528c43b5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:38 +0200 Subject: [PATCH 041/247] [Server] Gate room channels on privilege levels (#7477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Gate room channels on privilege levels * [Server] Filter channel catalog per user, split read/write gating, add banners - Server_Room::getInfo() now takes the requesting user and only lists channels that user may read. Previously every room member received the full catalog (e.g. the moderators channel) while message delivery was already gated, so restricted channels were visible but silent. - Split channel access into mayReadChannel/mayWriteChannel: history replay, catalog listing, and event fan-out use the read level (access_level); Command_RoomChannelSay uses the write level (write_access_level, falling back to the read level when unset). - Plumb write_access_level and banner_message through both room sources (SQL rooms_channels table and config-file channels via writeaccess and banner keys). - Schema 38 -> 39: add write_access_level (NULL means inherit the read level) and banner_message columns; also widen access_level to the full six-value privilege enum to match the 37 -> 38 migration. * Fixup: evaluate channel access per user flag instead of ladder User flags are independent bits: IsDeveloper does not imply IsModerator/IsAdmin, so developer-only users must not reach moderator/admin channels. Replace the userPrivilegeLevel ladder with hasChannelPrivilege: moderator channels need IsModerator (admins hold it alongside IsAdmin), admin channels need IsAdmin, developer channels need IsDeveloper or IsAdmin, judge channels admit IsJudge/IsModerator. Covers catalog filtering, history replay, write gating and fan-out, and corrects the ini example and proto docs. * [Server] Make channel privilege helpers static per review * [Server] Fail closed on unknown channel access levels per review - roomChannelAccessFromKey returns nullopt for unknown keywords instead of silently falling back to Public - skip channels with unknown read/write levels with qWarning, empty read defaults to Public, empty write means no override - mayWriteChannel now also requires read access so write-below-read configs cannot post into invisible channels * [Server] Remove stray conflict marker in channel replay comment * [Server] Fix servatrice.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../server/remote/server_protocolhandler.cpp | 17 +++-- .../network/server/remote/server_room.cpp | 72 +++++++++++++++++-- .../network/server/remote/server_room.h | 29 +++++++- .../protocol/pb/serverinfo_room_channel.proto | 6 +- .../migrations/servatrice_0038_to_0039.sql | 19 +++++ servatrice/servatrice.ini.example | 26 +++++-- servatrice/servatrice.sql | 4 +- servatrice/src/servatrice.cpp | 65 +++++++++++++++-- .../src/servatrice_database_interface.h | 2 +- 9 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 servatrice/migrations/servatrice_0038_to_0039.sql diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 0a91bbe77..8b4ade900 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -769,7 +769,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetGamesOfUser(const Command_G while (roomIterator.hasNext()) { Server_Room *room = roomIterator.next().value(); room->gamesLock.lockForRead(); - room->getInfo(*re->add_room_list(), false, true); + room->getInfo(*re->add_room_list(), false, true, true, userInfo); QListIterator gameIterator(room->getGamesOfUser(nameFromStdString(cmd.user_name()))); while (gameIterator.hasNext()) { re->add_game_list()->CopyFrom(gameIterator.next()); @@ -827,7 +827,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdListRooms(const Command_ListRo Event_ListRooms event; QMapIterator roomIterator(server->getRooms()); while (roomIterator.hasNext()) { - roomIterator.next().value()->getInfo(*event.add_room_list(), false); + roomIterator.next().value()->getInfo(*event.add_room_list(), false, false, true, userInfo); } rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); @@ -871,9 +871,13 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory)); } - // Replay the history of every catalog channel. Privilege filtering for - // restricted channels is applied on top of this in the moderator stack. + // Replay the history of every catalog channel the joining user may see. + // Public channels are replayed to everyone; restricted channels only to + // members whose privilege level meets the channel's access level. for (const ServerInfo_RoomChannel &channel : room->getChannels()) { + if (!room->mayReadChannel(*userInfo, channel)) { + continue; + } const QList channelHistory = room->getChatHistory(QString::fromStdString(channel.id())); for (const ServerInfo_ChatMessage &chatMessage : channelHistory) { Event_RoomChannelSay channelChatHistory; @@ -891,7 +895,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(joinMessageEvent)); auto *re = new Response_JoinRoom; - room->getInfo(*re->mutable_room_info(), true); + room->getInfo(*re->mutable_room_info(), true, false, true, userInfo); rc.setResponseExtension(re); return Response::RespOk; @@ -986,6 +990,9 @@ Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_R // The client only ever offers catalog ids; a crafted or stale id is rejected. return Response::RespInvalidCommand; } + if (!room->mayWriteChannel(*userInfo, *channel)) { + return Response::RespFunctionNotAllowed; + } if (!addSaidMessageSize(static_cast(cmd.message().size()))) { return Response::RespChatFlood; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 7a8c59536..68b137cd4 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -107,8 +107,11 @@ Server *Server_Room::getServer() const return static_cast(parent()); } -const ServerInfo_Room & -Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, bool includeExternalData) const +const ServerInfo_Room &Server_Room::getInfo(ServerInfo_Room &result, + bool complete, + bool showGameTypes, + bool includeExternalData, + const ServerInfo_User *forUser) const { result.set_room_id(id); result.set_name(name.toStdString()); @@ -158,6 +161,9 @@ Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, } for (const ServerInfo_RoomChannel &channel : channels) { + if (forUser != nullptr && !mayReadChannel(*forUser, channel)) { + continue; + } result.add_channel_list()->CopyFrom(channel); } @@ -179,6 +185,43 @@ const ServerInfo_RoomChannel *Server_Room::findChannel(const QString &channelId) return nullptr; } +bool Server_Room::hasChannelPrivilege(const ServerInfo_User &userInfo, + ServerInfo_RoomChannel::AccessLevel requiredLevel) +{ + // User flags are independent bits, not a ladder: holding IsDeveloper does + // not imply IsModerator or IsAdmin. The database grants admins the admin + // and the moderator bit together, so admins still reach moderator (and + // judge) channels; moderators likewise reach judge channels. + const uint32_t userLevel = userInfo.user_level(); + switch (requiredLevel) { + case ServerInfo_RoomChannel::Public: + return true; + case ServerInfo_RoomChannel::Registered: + return userLevel & ServerInfo_User::IsRegistered; + case ServerInfo_RoomChannel::Judge: + return userLevel & (ServerInfo_User::IsJudge | ServerInfo_User::IsModerator); + case ServerInfo_RoomChannel::Moderator: + return userLevel & ServerInfo_User::IsModerator; + case ServerInfo_RoomChannel::Admin: + return userLevel & ServerInfo_User::IsAdmin; + case ServerInfo_RoomChannel::Developer: + return userLevel & (ServerInfo_User::IsDeveloper | ServerInfo_User::IsAdmin); + default: + return false; + } +} + +bool Server_Room::mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel) +{ + return hasChannelPrivilege(userInfo, channel.access_level()); +} + +bool Server_Room::mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel) +{ + const auto writeLevel = channel.has_write_access_level() ? channel.write_access_level() : channel.access_level(); + return mayReadChannel(userInfo, channel) && hasChannelPrivilege(userInfo, writeLevel); +} + QList Server_Room::getChatHistory(const QString &channelId) const { return channelChatHistory.value(isMainChannel(channelId) ? MAIN_CHANNEL_KEY : channelId); @@ -342,18 +385,31 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl) { const bool isMain = isMainChannel(channelId); + ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public; + if (!isMain) { + const ServerInfo_RoomChannel *channel = findChannel(channelId); + if (channel == nullptr) { + // The channel was not whitelisted against the catalog; drop the message. + return; + } + accessLevel = channel->access_level(); + // V1 does not relay restricted channels across ISL (plan N5). + if (accessLevel != ServerInfo_RoomChannel::Public) { + sendToIsl = false; + } + } if (isMain) { Event_RoomSay event; event.set_name(userName.toStdString()); event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel); } else { Event_RoomChannelSay event; event.set_channel_id(channelId.toStdString()); event.set_name(userName.toStdString()); event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel); } if (chatHistorySize != 0) { @@ -405,13 +461,17 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s } } -void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl) +void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl, ServerInfo_RoomChannel::AccessLevel accessLevel) { usersLock.lockForRead(); { QMapIterator userIterator(users); while (userIterator.hasNext()) { - userIterator.next().value()->sendProtocolItem(*event); + Server_ProtocolHandler *user = userIterator.next().value(); + if (!hasChannelPrivilege(*user->getUserInfo(), accessLevel)) { + continue; + } + user->sendProtocolItem(*event); } } usersLock.unlock(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h index e9227862f..b6e6f269a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -119,6 +119,24 @@ public: const ServerInfo_RoomChannel *findChannel(const QString &channelId) const; /** @brief True when @p channelId refers to the implicit Main channel. */ static bool isMainChannel(const QString &channelId); + /** + * @brief True when the flags in @p userInfo satisfy @p requiredLevel. + * + * User flags are independent bits, not a ladder: IsDeveloper does not + * imply IsModerator or IsAdmin, so developer-only users cannot read or + * post in moderator/admin channels. Admins hold the moderator bit + * alongside the admin bit and moderators are admitted to judge channels. + */ + static bool hasChannelPrivilege(const ServerInfo_User &userInfo, ServerInfo_RoomChannel::AccessLevel requiredLevel); + /** @brief True when @p userInfo may read @p channel. */ + static bool mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel); + /** + * @brief True when @p userInfo may write to @p channel. + * + * Writing requires the channel's write level, or — when no explicit write + * level is configured — the same level as reading. + */ + static bool mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel); const QMap &getGames() const { return games; @@ -128,8 +146,11 @@ public: return externalGames; } Server *getServer() const; - const ServerInfo_Room & - getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const; + const ServerInfo_Room &getInfo(ServerInfo_Room &result, + bool complete, + bool showGameTypes = false, + bool includeExternalData = true, + const ServerInfo_User *forUser = nullptr) const; int getGamesCreatedByUser(const QString &name) const; QList getGamesOfUser(const QString &name) const; QList getChatHistory(const QString &channelId) const; @@ -155,7 +176,9 @@ public: void addGame(Server_Game *game); void removeGame(Server_Game *game); - void sendRoomEvent(RoomEvent *event, bool sendToIsl = true); + void sendRoomEvent(RoomEvent *event, + bool sendToIsl = true, + ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public); RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent); }; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto index d38b1f651..0f3a9ccad 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto @@ -2,8 +2,10 @@ syntax = "proto2"; message ServerInfo_RoomChannel { enum AccessLevel { - // Minimum privilege level, evaluated against the privilege ladder - // Public < Registered < Judge < Moderator < Admin < Developer. + // Required user flag set, evaluated per level (not as a ladder): + // Registered needs IsRegistered; Judge needs IsJudge or IsModerator; + // Moderator needs IsModerator (admins hold it alongside IsAdmin); + // Admin needs IsAdmin; Developer needs IsDeveloper or IsAdmin. Public = 0; Registered = 1; Judge = 2; diff --git a/servatrice/migrations/servatrice_0038_to_0039.sql b/servatrice/migrations/servatrice_0038_to_0039.sql new file mode 100644 index 000000000..0cad5f494 --- /dev/null +++ b/servatrice/migrations/servatrice_0038_to_0039.sql @@ -0,0 +1,19 @@ +-- Servatrice db migration from version 38 to version 39 + +-- Per-channel read/write split and banner messages. +-- +-- write_access_level gates sending: NULL (the default) means writing requires +-- the same level as reading (access_level), preserving the previous +-- single-level behavior. Set it higher than access_level for read-mostly +-- channels (e.g. a developer news feed readable by everyone but writable only +-- by developers). +-- +-- banner_message is a static notice shown at the top of the channel, +-- analogous to the room join message on Main. Empty = none. + +ALTER TABLE `cockatrice_rooms_channels` + MODIFY `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + ADD COLUMN `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL, + ADD COLUMN `banner_message` varchar(255) NOT NULL DEFAULT ''; + +UPDATE cockatrice_schema_version SET version=39 WHERE version=38; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index e45b38804..90207aa70 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -336,17 +336,35 @@ roomlist\1\game_types\3\name="GameType3" ; channel every room has. Each channel needs a stable ASCII id, a display name ; (server data, like the room name) and an access level. Supported levels, from ; least to most restrictive, are: public, registered, judge, moderator, admin -; and developer. A channel is readable and writable by every member whose own -; privilege level is at least the channel's level -- so a moderator channel is -; also reachable by admins and developers, and an admin channel by developers. +; and developer. Access is evaluated against the member's user flags, not as a +; ladder: a moderator channel admits moderators (admins hold the moderator +; flag alongside the admin flag, so they are admitted too), an admin channel +; admits only admins, and a developer channel admits only developers and +; admins. In particular, developers cannot read moderator or admin channels. +; A judge channel additionally admits moderators. ; Public channels are visible to every room member. -roomlist\1\channels\size=2 +; +; Write access defaults to the read level, so by default a channel is readable +; and writable by the same members. Set writeaccess higher for read-mostly +; channels (e.g. a developer news feed readable by everyone but writable only +; by developers). Members who can read but not write see the channel with a +; disabled message input. +; +; banner is an optional static notice shown at the top of the channel, similar +; to the room joinmessage on Main. Empty or unset = none. +roomlist\1\channels\size=3 roomlist\1\channels\1\id=looking-for-players roomlist\1\channels\1\displayname="Looking for players" roomlist\1\channels\1\access=public +roomlist\1\channels\1\banner="Find opponents here. Be friendly!" roomlist\1\channels\2\id=moderators roomlist\1\channels\2\displayname="Moderators" roomlist\1\channels\2\access=moderator +roomlist\1\channels\3\id=developer-news +roomlist\1\channels\3\displayname="Developer news" +roomlist\1\channels\3\access=public +roomlist\1\channels\3\writeaccess=developer +roomlist\1\channels\3\banner="Official announcements from the developers." [game] diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index c83f5bb06..839dbf572 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(38); +INSERT INTO cockatrice_schema_version VALUES(39); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -164,6 +164,8 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( `channel_id` varchar(50) NOT NULL, `display_name` varchar(50) NOT NULL, `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL, + `banner_message` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id_room`, `id_server`, `channel_id`), FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 9dd5a987c..040030320 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -68,6 +68,7 @@ #include #include #include +#include #include #include #include @@ -255,10 +256,10 @@ Servatrice::~Servatrice() * @brief Maps a configured channel access level (database column or ini value) * onto the ServerInfo_RoomChannel privilege ladder. * - * Unknown keywords fall back to Public so a typo never hides a channel that was - * meant to be broadly readable. + * Returns nullopt for unknown keywords so callers can fail closed instead of + * silently downgrading a restricted channel to Public. */ -static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QString &key) +static std::optional roomChannelAccessFromKey(const QString &key) { const QString level = key.trimmed().toLower(); if (level == "developer") { @@ -276,7 +277,10 @@ static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QStrin if (level == "registered") { return ServerInfo_RoomChannel::Registered; } - return ServerInfo_RoomChannel::Public; + if (level == "public") { + return ServerInfo_RoomChannel::Public; + } + return std::nullopt; } bool Servatrice::initServer() @@ -392,7 +396,8 @@ bool Servatrice::initServer() } QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery( - "select channel_id, display_name, access_level from {prefix}_rooms_channels where id_room = :id_room " + "select channel_id, display_name, access_level, write_access_level, banner_message from " + "{prefix}_rooms_channels where id_room = :id_room " "AND id_server = :id_server order by channel_id asc"); query3->bindValue(":id_server", serverId); query3->bindValue(":id_room", query->value(0).toInt()); @@ -414,10 +419,33 @@ bool Servatrice::initServer() continue; } seenChannelIds.insert(channelId); + const QString accessKey = query3->value(2).toString(); + auto accessLevel = + accessKey.trimmed().isEmpty() + ? std::optional(ServerInfo_RoomChannel::Public) + : roomChannelAccessFromKey(accessKey); + if (!accessLevel.has_value()) { + qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id" + << channelId; + continue; + } ServerInfo_RoomChannel channel; channel.set_id(channelId.toStdString()); channel.set_display_name(query3->value(1).toString().toStdString()); - channel.set_access_level(roomChannelAccessFromKey(query3->value(2).toString())); + channel.set_access_level(accessLevel.value()); + if (!query3->isNull(3)) { + const QString writeKey = query3->value(3).toString(); + if (!writeKey.trimmed().isEmpty()) { + auto writeLevel = roomChannelAccessFromKey(writeKey); + if (!writeLevel.has_value()) { + qWarning() << "Skipping room channel with unknown write access level" << writeKey + << "for id" << channelId; + continue; + } + channel.set_write_access_level(writeLevel.value()); + } + } + channel.set_banner_message(query3->value(4).toString().toStdString()); channels.append(channel); } @@ -456,10 +484,33 @@ bool Servatrice::initServer() continue; } seenChannelIds.insert(channelId); + const QString accessKey = settingsCache->value("access").toString(); + auto accessLevel = + accessKey.trimmed().isEmpty() + ? std::optional(ServerInfo_RoomChannel::Public) + : roomChannelAccessFromKey(accessKey); + if (!accessLevel.has_value()) { + qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id" + << channelId; + continue; + } ServerInfo_RoomChannel channel; channel.set_id(channelId.toStdString()); channel.set_display_name(settingsCache->value("displayname").toString().toStdString()); - channel.set_access_level(roomChannelAccessFromKey(settingsCache->value("access").toString())); + channel.set_access_level(accessLevel.value()); + if (settingsCache->contains("writeaccess")) { + const QString writeKey = settingsCache->value("writeaccess").toString(); + if (!writeKey.trimmed().isEmpty()) { + auto writeLevel = roomChannelAccessFromKey(writeKey); + if (!writeLevel.has_value()) { + qWarning() << "Skipping room channel with unknown write access level" << writeKey + << "for id" << channelId; + continue; + } + channel.set_write_access_level(writeLevel.value()); + } + } + channel.set_banner_message(settingsCache->value("banner").toString().toStdString()); channels.append(channel); } settingsCache->endArray(); diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0bae67e6c..2e0d69113 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -21,7 +21,7 @@ class QSqlQuery; -#define DATABASE_SCHEMA_VERSION 38 +#define DATABASE_SCHEMA_VERSION 39 class Servatrice; From c99113ab0efa94761ba752e61afddac1a2467f33 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:39 +0200 Subject: [PATCH 042/247] [Client] Tabbed room channels with unread states and collapsible chat (#7478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Add room channel selector with per-channel chat views * [Client] Tabbed room channels with unread states, collapse, read-only, banners Reworks the room chat channel display: channels move out of the combobox into a tab bar below the message input showing every channel available to the user. - Each tab shows its unread message count and is tinted when it holds unreads or a mention; both colors are user-configurable chat settings (channelUnreadColor, channelMentionColor) with a settings-page section. - Clicking the active tab collapses the chat: message view and input hide and the splitter section minimizes to the tab strip; expanding restores the previous splitter sizes. Viewing a channel clears its unread state. - Channels the user may read but not write (per write_access_level) show with the message input disabled and a read-only placeholder; sends are guarded locally, the server still enforces. - Channel banner_message strings render at the top of their channel view on join, like the room join message on Main. - Replayed channel history no longer inflates unread counts; moderation redaction now applies across all channel views. * Fixup: keep chat tab bar visible when chat is collapsed QSplitter children are collapsible by default, so setSizes({1, 0}) hid the whole chat group box including the channel tab bar. Mark the chat widget non-collapsible so collapsing clamps to its minimum size (title + tab bar) instead. * [Room] Explicitly initialize MAIN_CHANNEL per review * [Room] Address channel tab review follow-ups - gate @all mentions on moderator/admin sender to match ChatView, whole-word matching for own name and highlight words - hide single-channel tab bar so old servers do not show a lone Main tab that collapses chat on misclick - capture target ChatView in sendMessage so errors land in the channel the message was sent from - share ignore/unregistered filtering between RoomSay and RoomChannelSay via isRoomMessageIgnored * [Client] Fix tab_room.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../settings_page/messages_settings_page.cpp | 66 +++ .../settings_page/messages_settings_page.h | 11 + .../src/interface/widgets/tabs/tab_room.cpp | 420 ++++++++++++++++-- .../src/interface/widgets/tabs/tab_room.h | 32 +- .../interface_chat_settings_provider.h | 2 + .../libcockatrice/settings/chat_settings.cpp | 20 + .../libcockatrice/settings/chat_settings.h | 4 + tests/settings/settings_defaults_test.cpp | 26 ++ 8 files changed, 543 insertions(+), 38 deletions(-) diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 9494155af..6128a7830 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -118,6 +118,26 @@ MessagesSettingsPage::MessagesSettingsPage() highlightGroupBox = new QGroupBox; highlightGroupBox->setLayout(highlightNotice); + channelUnreadColor = new QLineEdit(); + channelUnreadColor->setText(SettingsCache::instance().chat().getChannelUnreadColor()); + updateChannelUnreadPreview(); + connect(channelUnreadColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelUnreadColor); + + channelMentionColor = new QLineEdit(); + channelMentionColor->setText(SettingsCache::instance().chat().getChannelMentionColor()); + updateChannelMentionPreview(); + connect(channelMentionColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelMentionColor); + + auto *channelGrid = new QGridLayout; + channelGrid->addWidget(&channelUnreadColorLabel, 0, 0); + channelGrid->addWidget(channelUnreadColor, 0, 1); + channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2); + channelGrid->addWidget(&channelMentionColorLabel, 1, 0); + channelGrid->addWidget(channelMentionColor, 1, 1); + channelGrid->addWidget(&hexChannelMentionLabel, 1, 2); + channelGroupBox = new QGroupBox; + channelGroupBox->setLayout(channelGrid); + messageList = new QListWidget; int count = SettingsCache::instance().messages().getCount(); @@ -159,6 +179,7 @@ MessagesSettingsPage::MessagesSettingsPage() mainLayout->addWidget(messageGroupBox); mainLayout->addWidget(chatGroupBox); mainLayout->addWidget(highlightGroupBox); + mainLayout->addWidget(channelGroupBox); setLayout(mainLayout); @@ -195,6 +216,34 @@ void MessagesSettingsPage::updateHighlightColor(const QString &value) } } +void MessagesSettingsPage::updateChannelUnreadColor(const QString &value) +{ +#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) + QColor colorToSet = QColor::fromString("#" + value); +#else + QColor colorToSet; + colorToSet.setNamedColor("#" + value); +#endif + if (colorToSet.isValid()) { + SettingsCache::instance().chat().setChannelUnreadColor(value); + updateChannelUnreadPreview(); + } +} + +void MessagesSettingsPage::updateChannelMentionColor(const QString &value) +{ +#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) + QColor colorToSet = QColor::fromString("#" + value); +#else + QColor colorToSet; + colorToSet.setNamedColor("#" + value); +#endif + if (colorToSet.isValid()) { + SettingsCache::instance().chat().setChannelMentionColor(value); + updateChannelMentionPreview(); + } +} + void MessagesSettingsPage::updateTextColor(QT_STATE_CHANGED_T value) { SettingsCache::instance().chat().setChatMentionForeground(value); @@ -221,6 +270,18 @@ void MessagesSettingsPage::updateHighlightPreview() ";color: " + (SettingsCache::instance().chat().getChatHighlightForeground() ? "white" : "black") + ";}"); } +void MessagesSettingsPage::updateChannelUnreadPreview() +{ + channelUnreadColor->setStyleSheet("QLineEdit{background:#" + + SettingsCache::instance().chat().getChannelUnreadColor() + ";}"); +} + +void MessagesSettingsPage::updateChannelMentionPreview() +{ + channelMentionColor->setStyleSheet("QLineEdit{background:#" + + SettingsCache::instance().chat().getChannelMentionColor() + ";}"); +} + void MessagesSettingsPage::storeSettings() { SettingsCache::instance().messages().setCount(messageList->count()); @@ -267,6 +328,9 @@ void MessagesSettingsPage::retranslateUi() { chatGroupBox->setTitle(tr("Chat settings")); highlightGroupBox->setTitle(tr("Custom alert words")); + channelGroupBox->setTitle(tr("Room channel tabs")); + channelUnreadColorLabel.setText(tr("Unread messages color")); + channelMentionColorLabel.setText(tr("Mentioned messages color")); chatMentionCheckBox.setText(tr("Enable chat mentions")); chatMentionCompleterCheckbox.setText(tr("Enable mention completer")); messageGroupBox->setTitle(tr("In-game message macros")); @@ -284,6 +348,8 @@ void MessagesSettingsPage::retranslateUi() useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs")); hexLabel.setText(tr("(Color is hexadecimal)")); hexHighlightLabel.setText(tr("(Color is hexadecimal)")); + hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)")); + hexChannelMentionLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); customAlertString->setPlaceholderText(tr("Word1 Word2 Word3")); aAdd->setText(tr("Add New Message")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h index e85e6cca7..112ff4e29 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -27,6 +27,8 @@ private slots: void actRemove(); void updateColor(const QString &value); void updateHighlightColor(const QString &value); + void updateChannelUnreadColor(const QString &value); + void updateChannelMentionColor(const QString &value); void updateTextColor(QT_STATE_CHANGED_T value); void updateTextHighlightColor(QT_STATE_CHANGED_T value); @@ -49,18 +51,27 @@ private: QCheckBox useGameTimeCheckBox; QGroupBox *chatGroupBox; QGroupBox *highlightGroupBox; + QGroupBox *channelGroupBox; QGroupBox *messageGroupBox; QLineEdit *mentionColor; QLineEdit *highlightColor; + QLineEdit *channelUnreadColor; + QLineEdit *channelMentionColor; QLineEdit *customAlertString; QLabel hexLabel; QLabel hexHighlightLabel; + QLabel channelUnreadColorLabel; + QLabel channelMentionColorLabel; + QLabel hexChannelUnreadLabel; + QLabel hexChannelMentionLabel; QLabel customAlertStringLabel; QLabel explainMessagesLabel; void storeSettings(); void updateMentionPreview(); void updateHighlightPreview(); + void updateChannelUnreadPreview(); + void updateChannelMentionPreview(); }; #endif // COCKATRICE_MESSAGES_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 67106ba34..e5f2020ca 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -38,9 +39,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -52,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -61,12 +65,45 @@ #include #include +namespace +{ +// Empty id refers to the implicit Main channel. +const QString MAIN_CHANNEL = QString(); + +/** + * @brief Privilege rank mirroring Server_Room::userPrivilegeLevel. + * + * Higher = more privileged. Used to evaluate channel write access locally so + * the input can reflect read-only channels; the server re-checks on send. + */ +int userPrivilegeRank(uint32_t userLevel) +{ + if (userLevel & ServerInfo_User::IsDeveloper) { + return static_cast(ServerInfo_RoomChannel::Developer); + } + if (userLevel & ServerInfo_User::IsAdmin) { + return static_cast(ServerInfo_RoomChannel::Admin); + } + if (userLevel & ServerInfo_User::IsModerator) { + return static_cast(ServerInfo_RoomChannel::Moderator); + } + if (userLevel & ServerInfo_User::IsJudge) { + return static_cast(ServerInfo_RoomChannel::Judge); + } + if (userLevel & ServerInfo_User::IsRegistered) { + return static_cast(ServerInfo_RoomChannel::Registered); + } + return static_cast(ServerInfo_RoomChannel::Public); +} +} // namespace + TabRoom::TabRoom(TabSupervisor *_tabSupervisor, AbstractClient *_client, ServerInfo_User *_ownUser, const ServerInfo_Room &info) : Tab(_tabSupervisor), client(_client), roomId(info.room_id()), roomName(QString::fromStdString(info.name())), - ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager()) + ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager()), activeChannelId(MAIN_CHANNEL), + chatCollapsed(false) { const int gameTypeListSize = info.gametype_list_size(); for (int i = 0; i < gameTypeListSize; ++i) { @@ -86,14 +123,61 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; userList->setGameInviteLinkProvider(gameInviteLinkProvider); - chatView = new ChatView(tabSupervisor, nullptr, true, this); - connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); - connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); - connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); - connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); - connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); - connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); - connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); + chatViewStack = new QStackedWidget; + channelTabBar = new QTabBar; + channelTabBar->setShape(QTabBar::RoundedSouth); + channelTabBar->setExpanding(true); + channelTabBar->setUsesScrollButtons(true); + + channelViews.insert(MAIN_CHANNEL, makeChatView()); + channelIds << MAIN_CHANNEL; + channelDisplayNames.insert(MAIN_CHANNEL, tr("Main")); + channelUnreadCounts.insert(MAIN_CHANNEL, 0); + channelMentioned.insert(MAIN_CHANNEL, false); + channelWritable.insert(MAIN_CHANNEL, true); + channelTabBar->addTab(channelDisplayNames.value(MAIN_CHANNEL)); + chatViewStack->addWidget(channelViews.value(MAIN_CHANNEL)); + + const uint32_t ownLevel = ownUser != nullptr ? ownUser->user_level() : 0; + const int ownRank = userPrivilegeRank(ownLevel); + + const int channelCount = info.channel_list_size(); + for (int i = 0; i < channelCount; ++i) { + const ServerInfo_RoomChannel &channel = info.channel_list(i); + const QString channelId = QString::fromStdString(channel.id()); + if (channelId.isEmpty()) { + continue; + } + QString displayName = QString::fromStdString(channel.display_name()); + if (displayName.isEmpty()) { + displayName = channelId; + } + const int writeLevel = channel.has_write_access_level() ? static_cast(channel.write_access_level()) + : static_cast(channel.access_level()); + ChatView *channelView = makeChatView(); + const QString banner = QString::fromStdString(channel.banner_message()); + if (!banner.isEmpty()) { + channelView->appendMessage(banner); + } + channelViews.insert(channelId, channelView); + channelIds << channelId; + channelDisplayNames.insert(channelId, displayName); + channelUnreadCounts.insert(channelId, 0); + channelMentioned.insert(channelId, false); + channelWritable.insert(channelId, ownRank >= writeLevel); + channelTabBar->addTab(displayName); + chatViewStack->addWidget(channelViews.value(channelId)); + } + + chatView = channelViews.value(MAIN_CHANNEL); + chatViewStack->setCurrentWidget(chatView); + channelTabBar->setCurrentIndex(0); + // Old servers never send a channel catalog, so the tab bar would only show + // a single "Main" tab where a misclick hides the chat. Only show it when + // there is more than one channel to switch between. + channelTabBar->setVisible(channelIds.size() > 1); + connect(channelTabBar, &QTabBar::currentChanged, this, &TabRoom::onChannelChanged); + connect(channelTabBar, &QTabBar::tabBarClicked, this, &TabRoom::onChannelTabClicked); connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this, &TabRoom::actCompleterChanged); sayLabel = new QLabel; @@ -101,6 +185,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, sayEdit->setMaxLength(MAX_TEXT_LENGTH); sayLabel->setBuddy(sayEdit); connect(sayEdit, &LineEditCompleter::returnPressed, this, &TabRoom::sendMessage); + updateSayEnabled(); auto *chatSettingsMenu = new QMenu(this); @@ -122,19 +207,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, sayHbox->addWidget(sayEdit); sayHbox->addWidget(chatSettingsButton); + sayWidget = new QWidget; + sayWidget->setLayout(sayHbox); + auto *chatVbox = new QVBoxLayout; - chatVbox->addWidget(chatView); - chatVbox->addLayout(sayHbox); + chatVbox->addWidget(chatViewStack); + chatVbox->addWidget(sayWidget); + chatVbox->addWidget(channelTabBar); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatVbox); - auto *splitter = new QSplitter(Qt::Vertical); - splitter->addWidget(gameSelector); - splitter->addWidget(chatGroupBox); + chatSplitter = new QSplitter(Qt::Vertical); + chatSplitter->addWidget(gameSelector); + chatSplitter->addWidget(chatGroupBox); + // Splitter children are collapsible by default, so a zero size would hide + // the whole chat group box including the tab bar. Keep it clamped to its + // minimum size (title + tab bar) when collapsed. + chatSplitter->setCollapsible(1, false); auto *hbox = new QHBoxLayout; - hbox->addWidget(splitter, 3); + hbox->addWidget(chatSplitter, 3); hbox->addWidget(userListPanel, 1); aLeaveRoom = new QAction(this); @@ -189,7 +282,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, void TabRoom::retranslateUi() { gameSelector->retranslateUi(); - chatView->retranslateUi(); + for (auto it = channelViews.constBegin(); it != channelViews.constEnd(); ++it) { + it.value()->retranslateUi(); + } userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); @@ -197,6 +292,11 @@ void TabRoom::retranslateUi() aLeaveRoom->setText(tr("&Leave room")); aClearChat->setText(tr("&Clear chat")); aOpenChatSettings->setText(tr("Chat Settings...")); + channelDisplayNames.insert(MAIN_CHANNEL, tr("Main")); + for (const QString &channelId : channelIds) { + updateChannelTab(channelId); + } + updateSayEnabled(); } void TabRoom::focusTab() @@ -233,6 +333,11 @@ void TabRoom::tabActivated() if (!sayEdit->hasFocus()) { sayEdit->setFocus(); } + if (!chatCollapsed) { + channelUnreadCounts[activeChannelId] = 0; + channelMentioned[activeChannelId] = false; + updateChannelTab(activeChannelId); + } } QString TabRoom::sanitizeHtml(QString dirty) const @@ -240,29 +345,232 @@ QString TabRoom::sanitizeHtml(QString dirty) const return dirty.replace("&", "&").replace("<", "<").replace(">", ">"); } +void TabRoom::updateSayEnabled() +{ + const bool writable = channelWritable.value(activeChannelId, true); + sayLabel->setEnabled(writable); + sayEdit->setEnabled(writable); + sayEdit->setPlaceholderText(writable ? QString() : tr("This channel is read-only.")); +} + void TabRoom::sendMessage() { + if (!channelWritable.value(activeChannelId, true)) { + return; + } if (sayEdit->text().isEmpty()) { return; } else if (sayEdit->hasVisibleCompleterPopup()) { sayEdit->hideCompleterPopups(); return; } else { - Command_RoomSay cmd; - cmd.set_message(sayEdit->text().toStdString()); + ChatView *targetView = channelViews.value(activeChannelId, chatView); + auto onFinished = [this, targetView](const Response &response) { + if (response.response_code() == Response::RespChatFlood) { + targetView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds.")); + } else if (response.response_code() == Response::RespFunctionNotAllowed || + response.response_code() == Response::RespInvalidCommand) { + targetView->appendMessage(tr("Your message could not be sent: the channel is not available or you " + "are not allowed to talk there.")); + } + }; - PendingCommand *pend = prepareRoomCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabRoom::sayFinished); - sendRoomCommand(pend); + if (activeChannelId.isEmpty()) { + Command_RoomSay cmd; + cmd.set_message(sayEdit->text().toStdString()); + + PendingCommand *pend = prepareRoomCommand(cmd); + connect(pend, &PendingCommand::finished, this, onFinished); + sendRoomCommand(pend); + } else { + Command_RoomChannelSay cmd; + cmd.set_channel_id(activeChannelId.toStdString()); + cmd.set_message(sayEdit->text().toStdString()); + + PendingCommand *pend = prepareRoomCommand(cmd); + connect(pend, &PendingCommand::finished, this, onFinished); + sendRoomCommand(pend); + } sayEdit->clear(); } } -void TabRoom::sayFinished(const Response &response) +void TabRoom::onChannelChanged(int index) { - if (response.response_code() == Response::RespChatFlood) { - chatView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds.")); + if (index < 0 || index >= channelIds.size()) { + return; } + activeChannelId = channelIds.at(index); + ChatView *view = channelViews.value(activeChannelId); + if (view) { + chatView = view; + chatViewStack->setCurrentWidget(view); + } + // Viewing a channel clears its unread state + channelUnreadCounts[activeChannelId] = 0; + channelMentioned[activeChannelId] = false; + updateChannelTab(activeChannelId); + updateSayEnabled(); + if (chatCollapsed) { + setChatCollapsed(false); + } +} + +void TabRoom::onChannelTabClicked(int index) +{ + if (index < 0 || index >= channelIds.size()) { + return; + } + // Clicking the active tab toggles collapse; clicking another tab switches to it + if (channelTabBar->currentIndex() == index && channelIds.at(index) == activeChannelId) { + setChatCollapsed(!chatCollapsed); + } +} + +void TabRoom::setChatCollapsed(bool collapsed) +{ + chatCollapsed = collapsed; + chatViewStack->setVisible(!collapsed); + sayWidget->setVisible(!collapsed); + if (collapsed) { + // Minimize the chat section of the splitter down to just the tab bar + savedSplitterSizes = chatSplitter->sizes(); + chatSplitter->setSizes({1, 0}); + } else { + // Expanding onto the active channel marks it read + channelUnreadCounts[activeChannelId] = 0; + channelMentioned[activeChannelId] = false; + updateChannelTab(activeChannelId); + chatViewStack->setCurrentWidget(chatView); + if (!savedSplitterSizes.isEmpty()) { + chatSplitter->setSizes(savedSplitterSizes); + } + } +} + +QColor TabRoom::parseColorHex(const QString &hex, const QColor &fallback) +{ +#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) + QColor color = QColor::fromString("#" + hex); +#else + QColor color; + color.setNamedColor("#" + hex); +#endif + return color.isValid() ? color : fallback; +} + +void TabRoom::updateChannelTab(const QString &channelId) +{ + int index = channelIds.indexOf(channelId); + if (index < 0) { + return; + } + QString baseName = channelDisplayNames.value(channelId, channelId.isEmpty() ? tr("Main") : channelId); + int unread = channelUnreadCounts.value(channelId, 0); + bool mentioned = channelMentioned.value(channelId, false); + + QString label = baseName; + if (unread > 0) { + label += tr(" (%1)").arg(unread); + } + channelTabBar->setTabText(index, label); + + if (mentioned) { + channelTabBar->setTabTextColor( + index, parseColorHex(SettingsCache::instance().chat().getChannelMentionColor(), QColor(194, 31, 47))); + } else if (unread > 0) { + channelTabBar->setTabTextColor( + index, parseColorHex(SettingsCache::instance().chat().getChannelUnreadColor(), QColor(26, 123, 0))); + } else { + channelTabBar->setTabTextColor(index, QColor()); + } +} + +bool TabRoom::messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const +{ + if (ownUser == nullptr) { + return false; + } + QString ownName = QString::fromStdString(ownUser->name()); + if (ownName.isEmpty()) { + return false; + } + // Match ChatView::checkMention / checkWord: whole-word, case-insensitive. + // "@zach" must not match "@zachary", and highlight words match full words. + auto containsWholeWord = [](const QString &haystack, const QString &needle) { + if (needle.isEmpty()) { + return false; + } + QRegularExpression re(QStringLiteral("\\b%1\\b").arg(QRegularExpression::escape(needle)), + QRegularExpression::CaseInsensitiveOption); + return re.match(haystack).hasMatch(); + }; + + if (containsWholeWord(message, "@" + ownName)) { + return true; + } + // ChatView only highlights @all for moderator/admin senders + // (ChatView::isModeratorSendingGlobal). Gating here keeps the tab + // mention color from drifting ahead of the message view. + const uint32_t senderLevel = senderInfo.user_level(); + const bool senderIsPrivileged = + (senderLevel & ServerInfo_User::IsModerator) || (senderLevel & ServerInfo_User::IsAdmin); + if (senderIsPrivileged && containsWholeWord(message, "@all")) { + return true; + } + const QStringList words = SettingsCache::instance().chat().getHighlightWords().split(' ', Qt::SkipEmptyParts); + for (const QString &word : words) { + if (containsWholeWord(message, word)) { + return true; + } + } + return false; +} + +void TabRoom::markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo) +{ + bool roomVisible = (tabSupervisor->currentWidget() == this); + bool viewing = (channelId == activeChannelId) && !chatCollapsed && roomVisible; + if (viewing) { + return; + } + channelUnreadCounts[channelId] = channelUnreadCounts.value(channelId, 0) + 1; + if (messageContainsMention(message, senderInfo)) { + channelMentioned[channelId] = true; + } + updateChannelTab(channelId); +} + +bool TabRoom::isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const +{ + if (userListProxy->isUserIgnored(senderName)) { + return true; + } + + UserListTWI *twi = userList->getUsers().value(senderName); + if (twi) { + userInfoOut = twi->getUserInfo(); + if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() && + !UserLevelFlags(userInfoOut.user_level()).testFlag(ServerInfo_User::IsRegistered)) { + return true; + } + } else { + userInfoOut = ServerInfo_User{}; + } + return false; +} + +ChatView *TabRoom::makeChatView() +{ + ChatView *view = new ChatView(tabSupervisor, nullptr, true, this); + connect(view, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); + connect(view, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); + connect(view, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); + connect(view, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); + connect(view, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); + connect(view, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); + connect(view, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); + return view; } void TabRoom::actClearChat() @@ -298,6 +606,9 @@ void TabRoom::processRoomEvent(const RoomEvent &event) case RoomEvent::ROOM_SAY: processRoomSayEvent(event.GetExtension(Event_RoomSay::ext)); break; + case RoomEvent::ROOM_CHANNEL_SAY: + processRoomChannelSayEvent(event.GetExtension(Event_RoomChannelSay::ext)); + break; case RoomEvent::REMOVE_MESSAGES: processRemoveMessagesEvent(event.GetExtension(Event_RemoveMessages::ext)); break; @@ -335,18 +646,9 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) QString senderName = QString::fromStdString(event.name()); QString message = QString::fromStdString(event.message()); - if (userListProxy->isUserIgnored(senderName)) { - return; - } - - UserListTWI *twi = userList->getUsers().value(senderName); ServerInfo_User userInfo = {}; - if (twi) { - userInfo = twi->getUserInfo(); - if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() && - !UserLevelFlags(userInfo.user_level()).testFlag(ServerInfo_User::IsRegistered)) { - return; - } + if (isRoomMessageIgnored(senderName, userInfo)) { + return; } if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) { @@ -360,7 +662,49 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) "] " + message; } - chatView->appendMessage(message, event.message_type(), userInfo, true); + ChatView *mainView = channelViews.value(MAIN_CHANNEL); + if (mainView == nullptr) { + return; + } + + mainView->appendMessage(message, event.message_type(), userInfo, true); + if (event.message_type() != Event_RoomSay::ChatHistory) { + markChannelMessage(MAIN_CHANNEL, message, userInfo); + } + emit userEvent(false); +} + +void TabRoom::processRoomChannelSayEvent(const Event_RoomChannelSay &event) +{ + const QString channelId = QString::fromStdString(event.channel_id()); + ChatView *view = channelViews.value(channelId); + if (view == nullptr) { + return; + } + + QString senderName = QString::fromStdString(event.name()); + QString message = QString::fromStdString(event.message()); + + ServerInfo_User userInfo = {}; + if (isRoomMessageIgnored(senderName, userInfo)) { + return; + } + + if (senderName.isEmpty()) { + if (!SettingsCache::instance().chat().getRoomHistory()) { + return; + } + message = + "[" + + QString(QDateTime::fromMSecsSinceEpoch(event.time_of()).toLocalTime().toString("d MMM yyyy HH:mm:ss")) + + "] " + message; + } + + view->appendMessage(message, {}, userInfo, true); + if (!senderName.isEmpty()) { + // Empty senders mark replayed history, which must not count as unread + markChannelMessage(channelId, message, userInfo); + } emit userEvent(false); } @@ -368,7 +712,9 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event) { QString userName = QString::fromStdString(event.name()); int amount = event.amount(); - chatView->redactMessages(userName, amount); + for (ChatView *view : channelViews) { + view->redactMessages(userName, amount); + } } void TabRoom::refreshShortcuts() diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index 031056145..8f7c8f918 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -10,6 +10,7 @@ #include "tab.h" +#include #include #include #include @@ -18,8 +19,12 @@ class UserListProxy; class QAction; +class QTabBar; +class QWidget; class QGroupBox; +class QSplitter; class QMenu; +class QStackedWidget; class QStringListModel; class TabSupervisor; @@ -42,6 +47,7 @@ class Event_ListGames; class Event_JoinRoom; class Event_LeaveRoom; class Event_RoomSay; +class Event_RoomChannelSay; class Event_RemoveMessages; class GameSelector; class Response; @@ -64,10 +70,23 @@ private: UserListWidget *userList; const UserListProxy *userListProxy; ChatView *chatView; + QStackedWidget *chatViewStack; + QTabBar *channelTabBar; + QWidget *sayWidget; + QMap channelViews; + QMap channelDisplayNames; + QMap channelUnreadCounts; + QMap channelMentioned; + QMap channelWritable; + QStringList channelIds; + QString activeChannelId; + bool chatCollapsed; QLabel *sayLabel; LineEditCompleter *sayEdit; QStringListModel *mentionModel; QGroupBox *chatGroupBox; + QSplitter *chatSplitter; + QList savedSplitterSizes; QMenu *roomMenu; QAction *aLeaveRoom; @@ -85,7 +104,9 @@ signals: void gameListUpdated(); private slots: void sendMessage(); - void sayFinished(const Response &response); + void onChannelChanged(int index); + void onChannelTabClicked(int index); + void setChatCollapsed(bool collapsed); void actClearChat(); void actOpenChatSettings(); void addMentionTag(QString mentionTag); @@ -98,9 +119,18 @@ private slots: void processJoinRoomEvent(const Event_JoinRoom &event); void processLeaveRoomEvent(const Event_LeaveRoom &event); void processRoomSayEvent(const Event_RoomSay &event); + void processRoomChannelSayEvent(const Event_RoomChannelSay &event); void processRemoveMessagesEvent(const Event_RemoveMessages &event); void refreshShortcuts(); + ChatView *makeChatView(); + void updateChannelTab(const QString &channelId); + void updateSayEnabled(); + void markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo); + [[nodiscard]] bool messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const; + [[nodiscard]] bool isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const; + static QColor parseColorHex(const QString &hex, const QColor &fallback); + protected slots: void closeEvent(QCloseEvent *event) override; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h index 04dad9243..03f98f7bd 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h @@ -12,6 +12,8 @@ public: [[nodiscard]] virtual bool getChatMentionCompleter() const = 0; [[nodiscard]] virtual QString getChatMentionColor() const = 0; [[nodiscard]] virtual QString getChatHighlightColor() const = 0; + [[nodiscard]] virtual QString getChannelUnreadColor() const = 0; + [[nodiscard]] virtual QString getChannelMentionColor() const = 0; [[nodiscard]] virtual bool getChatMentionForeground() const = 0; [[nodiscard]] virtual bool getChatHighlightForeground() const = 0; [[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0; diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp index d9ae0ddac..10567f2ff 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp @@ -31,6 +31,16 @@ QString ChatSettings::getChatHighlightColor() const return getValue("highlightColor", QString(), QString(), "A6120D").toString(); } +QString ChatSettings::getChannelUnreadColor() const +{ + return getValue("channelUnreadColor", QString(), QString(), "1A7B00").toString(); +} + +QString ChatSettings::getChannelMentionColor() const +{ + return getValue("channelMentionColor", QString(), QString(), "A6120D").toString(); +} + bool ChatSettings::getChatMentionForeground() const { return getValue("mentionForeground", QString(), QString(), true).toBool(); @@ -107,6 +117,16 @@ void ChatSettings::setChatHighlightColor(const QString &_chatHighlightColor) setValue(_chatHighlightColor, "highlightColor"); } +void ChatSettings::setChannelUnreadColor(const QString &_channelUnreadColor) +{ + setValue(_channelUnreadColor, "channelUnreadColor"); +} + +void ChatSettings::setChannelMentionColor(const QString &_channelMentionColor) +{ + setValue(_channelMentionColor, "channelMentionColor"); +} + void ChatSettings::setChatMentionForeground(bool _chatMentionForeground) { setValue(_chatMentionForeground, "mentionForeground"); diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.h b/libcockatrice_settings/libcockatrice/settings/chat_settings.h index e00da4527..d107dfb67 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h @@ -19,6 +19,8 @@ public: [[nodiscard]] bool getChatMentionCompleter() const override; [[nodiscard]] QString getChatMentionColor() const override; [[nodiscard]] QString getChatHighlightColor() const override; + [[nodiscard]] QString getChannelUnreadColor() const override; + [[nodiscard]] QString getChannelMentionColor() const override; [[nodiscard]] bool getChatMentionForeground() const override; [[nodiscard]] bool getChatHighlightForeground() const override; [[nodiscard]] bool getIgnoreUnregisteredUsers() const override; @@ -35,6 +37,8 @@ public: void setChatMentionCompleter(bool _chatMentionCompleter); void setChatMentionColor(const QString &_chatMentionColor); void setChatHighlightColor(const QString &_chatHighlightColor); + void setChannelUnreadColor(const QString &_channelUnreadColor); + void setChannelMentionColor(const QString &_channelMentionColor); void setChatMentionForeground(bool _chatMentionForeground); void setChatHighlightForeground(bool _chatHighlightForeground); void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 2ba9f3fbb..0d4cdbafe 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -285,6 +285,32 @@ TEST_F(SettingsDefaultsTest, Chat_HighlightColor_Default) ASSERT_EQ(s.getChatHighlightColor(), QString("A6120D")); } +TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChannelUnreadColor(), QString("1A7B00")); +} + +TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_SetAndGet) +{ + ChatSettings s(settingsPath, nullptr); + s.setChannelUnreadColor(QString("0000FF")); + ASSERT_EQ(s.getChannelUnreadColor(), QString("0000FF")); +} + +TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChannelMentionColor(), QString("A6120D")); +} + +TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_SetAndGet) +{ + ChatSettings s(settingsPath, nullptr); + s.setChannelMentionColor(QString("FF0000")); + ASSERT_EQ(s.getChannelMentionColor(), QString("FF0000")); +} + TEST_F(SettingsDefaultsTest, Chat_MentionForeground_Default) { ChatSettings s(settingsPath, nullptr); From ce51e7147dc029327733ca0decb3b6a86c8ac259 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:39 +0200 Subject: [PATCH 043/247] [Servatrice] Log room channel messages in audit log (#7479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Log room channel messages and bump schema to 38 * Fixup: show room_channel audit rows and keep migration nullable The log history query returns room_channel rows, but the log tab dropped them. Render them in the room table with an extra Channel column. Also drop the NOT NULL on target_type from the 37->38 migration so migrated schemas match the nullable servatrice.sql definition (and strict-mode ALTERs cannot fail on legacy NULL rows). Update the channel tests to the flag-based privilege model: the admin fixture now carries IsModerator alongside IsAdmin, as the database layer assigns. --------- Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_logs.cpp | 12 +- .../server/remote/server_database_interface.h | 6 +- .../server/remote/server_protocolhandler.cpp | 4 + .../migrations/servatrice_0037_to_0038.sql | 8 + servatrice/servatrice.sql | 3 +- .../src/servatrice_database_interface.cpp | 20 +- .../src/servatrice_database_interface.h | 3 +- tests/CMakeLists.txt | 11 + tests/server_room_channels_test.cpp | 550 ++++++++++++++++++ 9 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 tests/server_room_channels_test.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index 9f17afea2..67e076983 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -35,10 +35,10 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); - roomTable->setColumnCount(6); + roomTable->setColumnCount(7); roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); roomTable->setHorizontalHeaderLabels( - QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";")); + QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName;Channel")).split(";")); gameTable = new QTableWidget(); gameTable->setColumnCount(6); @@ -317,7 +317,8 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) for (int i = 0; i < response.log_message_size(); ++i) { ServerInfo_ChatMessage message = response.log_message(i); - if (QString::fromStdString(message.target_type()) == "room") { + const QString targetType = QString::fromStdString(message.target_type()); + if (targetType == "room" || targetType == "room_channel") { roomTable->insertRow(roomCounter); roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); @@ -325,10 +326,11 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); + roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel()))); ++roomCounter; } - if (QString::fromStdString(message.target_type()) == "game") { + if (targetType == "game") { gameTable->insertRow(gameCounter); gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); @@ -339,7 +341,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) ++gameCounter; } - if (QString::fromStdString(message.target_type()) == "chat") { + if (targetType == "chat") { chatTable->insertRow(chatCounter); chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index 167adfeaf..3fef26904 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -151,7 +151,8 @@ public: MessageTargetRoom, MessageTargetGame, MessageTargetChat, - MessageTargetIslRoom + MessageTargetIslRoom, + MessageTargetRoomChannel }; virtual void logMessage(const int /* senderId */, const QString & /* senderName */, @@ -159,7 +160,8 @@ public: const QString & /* logMessage */, LogMessage_TargetType /* targetType */, const int /* targetId */, - const QString & /* targetName */) + const QString & /* targetName */, + const QString & /* channel */ = QString()) { } virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 8b4ade900..f75e2d3e3 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1003,6 +1003,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_R room->say(QString::fromStdString(userInfo->name()), msg, channelId); + databaseInterface->logMessage( + userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()), msg, + Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(), room->getName(), channelId); + return Response::RespOk; } diff --git a/servatrice/migrations/servatrice_0037_to_0038.sql b/servatrice/migrations/servatrice_0037_to_0038.sql index 6b8f155ab..ad6477c46 100644 --- a/servatrice/migrations/servatrice_0037_to_0038.sql +++ b/servatrice/migrations/servatrice_0037_to_0038.sql @@ -18,4 +18,12 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +-- 2. Audit log: room_channel target type plus a channel column so moderators +-- can tell which stream a logged message belongs to. +ALTER TABLE `cockatrice_log` + MODIFY `target_type` ENUM('room', 'game', 'chat', 'room_channel'); + +ALTER TABLE `cockatrice_log` + ADD COLUMN `channel` varchar(50) NULL AFTER `target_name`; + UPDATE cockatrice_schema_version SET version=38 WHERE version=37; \ No newline at end of file diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 839dbf572..438b32ccd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -341,9 +341,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_log` ( `sender_name` varchar(35) NOT NULL, `sender_ip` varchar(45) NOT NULL, `log_message` text NOT NULL, - `target_type` ENUM('room', 'game', 'chat'), + `target_type` ENUM('room', 'game', 'chat', 'room_channel'), `target_id` int(7) NULL, `target_name` varchar(50) NOT NULL, + `channel` varchar(50) NULL, KEY `sender_name` (`sender_name`), KEY `sender_ip` (`sender_ip`), KEY `target_id` (`target_id`), diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index e7d46c050..d7912cfe6 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1286,7 +1286,8 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName) + const QString &targetName, + const QString &channel) { QString targetTypeString; switch (targetType) { @@ -1296,6 +1297,12 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, } targetTypeString = "room"; break; + case MessageTargetRoomChannel: + if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) { + return; + } + targetTypeString = "room_channel"; + break; case MessageTargetGame: if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) { return; @@ -1319,8 +1326,9 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, } QSqlQuery *query = prepareQuery("insert into {prefix}_log (log_time, sender_id, sender_name, sender_ip, " - "log_message, target_type, target_id, target_name) values (now(), :sender_id, " - ":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)"); + "log_message, target_type, target_id, target_name, channel) values (now(), " + ":sender_id, :sender_name, :sender_ip, :log_message, :target_type, :target_id, " + ":target_name, :channel)"); query->bindValue(":sender_id", senderId < 1 ? QVariant() : senderId); query->bindValue(":sender_name", senderName); query->bindValue(":sender_ip", senderIp); @@ -1328,6 +1336,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, query->bindValue(":target_type", targetTypeString); query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId); query->bindValue(":target_name", targetName); + query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel); execSqlQuery(query); } @@ -1846,9 +1855,9 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory if (room) { if (game || chat) { - queryString.append(" OR `target_type` = 'room'"); + queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'"); } else { - queryString.append("`target_type` = 'room'"); + queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'"); } } queryString.append(")"); @@ -1899,6 +1908,7 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory chatMessage.set_target_type(QString(query->value(5).toString()).toStdString()); chatMessage.set_target_id(QString(query->value(6).toString()).toStdString()); chatMessage.set_target_name(QString(query->value(7).toString()).toStdString()); + chatMessage.set_channel(QString(query->value(8).toString()).toStdString()); results << chatMessage; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 2e0d69113..eccd0d943 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -177,7 +177,8 @@ public: const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName) override; + const QString &targetName, + const QString &channel = QString()) override; bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override; bool changeUserPassword(const QString &user, const QString &oldPassword, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ee0fd0698..b58942f3f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -274,6 +274,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_room_channels_test + SOURCES + server_room_channels_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME latency_tracker_test diff --git a/tests/server_room_channels_test.cpp b/tests/server_room_channels_test.cpp new file mode 100644 index 000000000..006c9fa39 --- /dev/null +++ b/tests/server_room_channels_test.cpp @@ -0,0 +1,550 @@ +/** @file server_room_channels_test.cpp + * @brief Tests for room chat channels: fan-out, privilege gating, whitelist, history replay and audit logging. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_protocolhandler.h" +#include "server_room.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + struct LogEntry + { + QString senderName; + QString message; + LogMessage_TargetType targetType; + int targetId; + QString targetName; + QString channel; + }; + QList logEntries; + + void logMessage(const int /* senderId */, + const QString &senderName, + const QString & /* senderIp */, + const QString &message, + LogMessage_TargetType targetType, + const int targetId, + const QString &targetName, + const QString &channel = QString()) override + { + logEntries.append({senderName, message, targetType, targetId, targetName, channel}); + } + + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return NotLoggedIn; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } + ServerInfo_User getUserData(const QString &, bool) override + { + return ServerInfo_User(); + } +}; + +class FakeServer : public Server +{ +public: + explicit FakeServer(Server_DatabaseInterface *db) : Server() + { + setDatabaseInterface(db); + } + void insertRoom(Server_Room *newRoom) + { + addRoom(newRoom); + } +}; + +class TestRoomHandler : public Server_ProtocolHandler +{ +public: + TestRoomHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + void authenticate(const ServerInfo_User &userInfo_) + { + setUserInfo(userInfo_); + authState = PasswordRight; + } + void attachToRoom(Server_Room *targetRoom) + { + rooms.insert(targetRoom->getId(), targetRoom); + } + + QList receivedRoomEvents; + Response::ResponseCode lastResponseCode = Response::RespNothing; + + void clearReceived() + { + receivedRoomEvents.clear(); + lastResponseCode = Response::RespNothing; + } + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::ROOM_EVENT) { + receivedRoomEvents.append(item.room_event()); + } else if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } +}; + +class RoomChannelTest : public ::testing::Test +{ +protected: + MockDatabaseInterface db; + FakeServer server{&db}; + QList catalog; + Server_Room *room = nullptr; + TestRoomHandler mod{&server, &db}; + TestRoomHandler adminUser{&server, &db}; + TestRoomHandler user{&server, &db}; + ServerInfo_User moderator; + ServerInfo_User admin; + ServerInfo_User plainUser; + + void SetUp() override + { + ServerInfo_RoomChannel lobby; + lobby.set_id("lobby"); + lobby.set_display_name("Lobby"); + lobby.set_access_level(ServerInfo_RoomChannel::Public); + catalog.append(lobby); + + ServerInfo_RoomChannel mods; + mods.set_id("mods"); + mods.set_display_name("Moderators"); + mods.set_access_level(ServerInfo_RoomChannel::Moderator); + catalog.append(mods); + + ServerInfo_RoomChannel ops; + ops.set_id("ops"); + ops.set_display_name("Admins"); + ops.set_access_level(ServerInfo_RoomChannel::Admin); + catalog.append(ops); + + room = new Server_Room(1, 1000, "test room", "", "", "", false, "Welcome", QStringList(), &server, catalog); + + moderator.set_name("mod"); + moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | + ServerInfo_User::IsModerator); + // Mirrors the database layer: the admin bit grants IsAdmin alongside + // IsModerator, so admins reach moderator channels via the moderator flag. + admin.set_name("admin"); + admin.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsAdmin | + ServerInfo_User::IsModerator); + plainUser.set_name("user"); + plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + } + + void TearDown() override + { + delete room; + } + + // Make @p handler a member of the room directly, without history replay or JOIN_ROOM bookkeeping. + void joinMember(TestRoomHandler &handler, const ServerInfo_User &userInfo_) + { + handler.authenticate(userInfo_); + handler.attachToRoom(room); + room->addClient(&handler); + } + + void sendMainSay(TestRoomHandler &handler, const QString &message) + { + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(room->getId()); + Command_RoomSay cmd; + cmd.set_message(message.toStdString()); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + void sendChannelSay(TestRoomHandler &handler, const QString &channelId, const QString &message) + { + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(room->getId()); + Command_RoomChannelSay cmd; + cmd.set_channel_id(channelId.toStdString()); + cmd.set_message(message.toStdString()); + cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + void joinThroughCommand(TestRoomHandler &handler) + { + CommandContainer cont; + cont.set_cmd_id(1); + Command_JoinRoom cmd; + cmd.set_room_id(room->getId()); + cont.add_session_command()->MutableExtension(Command_JoinRoom::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + static QList channelSayEvents(const TestRoomHandler &handler) + { + QList result; + for (const RoomEvent &event : handler.receivedRoomEvents) { + if (static_cast(getPbExtension(event)) == RoomEvent::ROOM_CHANNEL_SAY) { + result.append(event); + } + } + return result; + } + + static QList roomSayEvents(const TestRoomHandler &handler) + { + QList result; + for (const RoomEvent &event : handler.receivedRoomEvents) { + if (static_cast(getPbExtension(event)) == RoomEvent::ROOM_SAY) { + result.append(event); + } + } + return result; + } + + static QString channelIdOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).channel_id()); + } + + static QString messageOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).message()); + } + + static QString mainMessageOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomSay::ext).message()); + } +}; + +TEST_F(RoomChannelTest, MainSayStillBroadcastsToAllRoomMembers) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendMainSay(user, "hello everyone"); + + EXPECT_EQ(user.lastResponseCode, Response::RespOk); + EXPECT_EQ(roomSayEvents(mod).size(), 1); + EXPECT_EQ(mainMessageOf(roomSayEvents(mod).first()), "hello everyone"); + EXPECT_EQ(roomSayEvents(user).size(), 1); + EXPECT_EQ(mainMessageOf(roomSayEvents(user).first()), "hello everyone"); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, PublicChannelSaysBroadcastToEveryone) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendChannelSay(user, "lobby", "is there a game tonight?"); + + EXPECT_EQ(user.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "lobby"); + EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "is there a game tonight?"); + EXPECT_EQ(channelSayEvents(user).size(), 1); +} + +TEST_F(RoomChannelTest, ModeratorChannelReachesModeratorAndAbove) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + joinMember(user, plainUser); + mod.clearReceived(); + adminUser.clearReceived(); + user.clearReceived(); + + sendChannelSay(mod, "mods", "quiet channel"); + + EXPECT_EQ(mod.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "mods"); + // An admin holds the moderator flag alongside the admin flag and so also receives the channel message. + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + EXPECT_TRUE(roomSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, AdminReachesModeratorChannel) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + mod.clearReceived(); + adminUser.clearReceived(); + + sendChannelSay(adminUser, "mods", "admin checking in"); + + EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "admin checking in"); +} + +TEST_F(RoomChannelTest, UserBelowRequiredLevelCannotSendToChannel) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendChannelSay(user, "mods", "let me in"); + + EXPECT_EQ(user.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + bool loggedAsChannel = false; + for (const MockDatabaseInterface::LogEntry &entry : db.logEntries) { + loggedAsChannel |= entry.targetType == Server_DatabaseInterface::MessageTargetRoomChannel; + } + EXPECT_FALSE(loggedAsChannel); +} + +TEST_F(RoomChannelTest, AdminChannelRejectsModerator) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + mod.clearReceived(); + adminUser.clearReceived(); + + sendChannelSay(mod, "ops", "not allowed"); + + EXPECT_EQ(mod.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(adminUser).isEmpty()); +} + +TEST_F(RoomChannelTest, AdminChannelReachesAdminsOnly) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + joinMember(user, plainUser); + mod.clearReceived(); + adminUser.clearReceived(); + user.clearReceived(); + + sendChannelSay(adminUser, "ops", "staff only"); + + EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, UnknownChannelIdIsRejected) +{ + joinMember(user, plainUser); + user.clearReceived(); + + sendChannelSay(user, "not-a-channel", "spam"); + + EXPECT_EQ(user.lastResponseCode, Response::RespInvalidCommand); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + EXPECT_TRUE(db.logEntries.isEmpty()); +} + +TEST_F(RoomChannelTest, MissingAccessLevelDefaultsToPublic) +{ + ServerInfo_RoomChannel defaultAccess; + defaultAccess.set_id("public-by-default"); + defaultAccess.set_display_name("Default Access"); + catalog.append(defaultAccess); + auto *secondRoom = new Server_Room(2, 1000, "second", "", "", "", false, "", QStringList(), &server, catalog); + + TestRoomHandler joiner{&server, &db}; + joiner.authenticate(plainUser); + joiner.attachToRoom(secondRoom); + secondRoom->addClient(&joiner); + + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(2); + Command_RoomChannelSay cmd; + cmd.set_channel_id("public-by-default"); + cmd.set_message("hi"); + cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd); + joiner.processCommandContainer(cont); + + EXPECT_EQ(joiner.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(joiner).size(), 1); + + delete secondRoom; +} + +TEST_F(RoomChannelTest, HistoryStoredPerChannel) +{ + room->say("user", "main message", QString(), false); + room->say("user", "lobby message", "lobby", false); + room->say("user", "mods message", "mods", false); + + const QList mainHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY); + EXPECT_EQ(mainHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(mainHistory.first().message()), "main message"); + EXPECT_TRUE(QString::fromStdString(mainHistory.first().channel()).isEmpty()); + + const QList lobbyHistory = room->getChatHistory("lobby"); + EXPECT_EQ(lobbyHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(lobbyHistory.first().message()), "lobby message"); + EXPECT_EQ(QString::fromStdString(lobbyHistory.first().channel()), "lobby"); + + const QList modsHistory = room->getChatHistory("mods"); + EXPECT_EQ(modsHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(modsHistory.first().message()), "mods message"); + EXPECT_EQ(QString::fromStdString(modsHistory.first().channel()), "mods"); + + // A channel id maps to its own history bucket, never to the main one. + EXPECT_EQ(room->getChatHistory("").size(), mainHistory.size()); +} + +TEST_F(RoomChannelTest, JoinReplaysOnlyVisibleChannelHistory) +{ + server.insertRoom(room); + joinMember(mod, moderator); + joinMember(user, plainUser); + + room->say("user", "main message", QString(), false); + room->say("user", "lobby message", "lobby", false); + room->say("mod", "mods message", "mods", false); + room->say("admin", "ops message", "ops", false); + + TestRoomHandler lateUser{&server, &db}; + lateUser.authenticate(plainUser); + joinThroughCommand(lateUser); + + EXPECT_EQ(lateUser.lastResponseCode, Response::RespOk); + const QList lateChannelEvents = channelSayEvents(lateUser); + // Replay: one main history event, one public channel history event, the welcome message -- but no restricted + // history. + EXPECT_EQ(roomSayEvents(lateUser).size(), 2); + EXPECT_EQ(lateChannelEvents.size(), 1); + EXPECT_EQ(channelIdOf(lateChannelEvents.first()), "lobby"); + + TestRoomHandler lateMod{&server, &db}; + lateMod.authenticate(moderator); + joinThroughCommand(lateMod); + + EXPECT_EQ(lateMod.lastResponseCode, Response::RespOk); + const QList lateModChannelEvents = channelSayEvents(lateMod); + EXPECT_EQ(lateModChannelEvents.size(), 2); + EXPECT_EQ(channelIdOf(lateModChannelEvents.at(0)), "lobby"); + EXPECT_EQ(channelIdOf(lateModChannelEvents.at(1)), "mods"); + + TestRoomHandler lateAdmin{&server, &db}; + lateAdmin.authenticate(admin); + joinThroughCommand(lateAdmin); + + EXPECT_EQ(lateAdmin.lastResponseCode, Response::RespOk); + const QList lateAdminChannelEvents = channelSayEvents(lateAdmin); + // An admin sees the moderator channel and the admin channel on top of the public one. + EXPECT_EQ(lateAdminChannelEvents.size(), 3); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(0)), "lobby"); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(1)), "mods"); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(2)), "ops"); +} + +TEST_F(RoomChannelTest, AuditLoggingTagsChannelMessages) +{ + joinMember(user, plainUser); + + sendMainSay(user, "main log line"); + sendChannelSay(user, "lobby", "channel log line"); + + ASSERT_EQ(db.logEntries.size(), 2); + EXPECT_EQ(db.logEntries.at(0).targetType, Server_DatabaseInterface::MessageTargetRoom); + EXPECT_TRUE(db.logEntries.at(0).channel.isEmpty()); + EXPECT_EQ(db.logEntries.at(1).targetType, Server_DatabaseInterface::MessageTargetRoomChannel); + EXPECT_EQ(db.logEntries.at(1).channel, "lobby"); + EXPECT_EQ(db.logEntries.at(1).senderName, "user"); + EXPECT_EQ(db.logEntries.at(1).message, "channel log line"); +} + +TEST_F(RoomChannelTest, PublicChannelRelaysOverIslButRestrictedChannelsDoNot) +{ + QList relMessages; + QObject::connect(&server, &Server::sigSendIslMessage, + [&relMessages](const IslMessage &message, int) { relMessages.append(message); }); + + room->say("mod", "public msg", "lobby"); + room->say("mod", "mods msg", "mods"); + room->say("admin", "ops msg", "ops"); + + ASSERT_EQ(relMessages.size(), 1); + const RoomEvent &relayed = relMessages.first().room_event(); + EXPECT_EQ(static_cast(getPbExtension(relayed)), RoomEvent::ROOM_CHANNEL_SAY); + EXPECT_EQ(channelIdOf(relayed), "lobby"); + EXPECT_EQ(messageOf(relayed), "public msg"); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file From 155f7b01433c3466bb95883afe5f3667c34fe6db Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:40 +0200 Subject: [PATCH 044/247] [Servatrice] Relay room channel messages over ISL (#7480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Relay room channel messages over ISL * [Servatrice] Skip audit log for unknown ISL channel per review - externalRoomSay drops messages for channels not in the local catalog, matching Server_Room::say, instead of logging what was never delivered --------- Co-authored-by: Lukas Brübach --- .../network/server/remote/server.cpp | 20 +++++++++++++++---- .../network/server/remote/server.h | 2 +- servatrice/src/isl_interface.cpp | 13 +++++++++++- servatrice/src/isl_interface.h | 4 +++- servatrice/src/servatrice.cpp | 4 ++-- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index d0fb67446..63989bbfa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -441,7 +441,7 @@ void Server::externalRoomUserLeft(int roomId, const QString &userName) room->removeExternalUser(userName); } -void Server::externalRoomSay(int roomId, const QString &userName, const QString &message) +void Server::externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId) { // This function is always called from the main thread via signal/slot. QReadLocker locker(&roomsLock); @@ -451,10 +451,22 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString qDebug() << "externalRoomSay: room id=" << roomId << "not found"; return; } - room->say(userName, message, QString(), false); + if (!channelId.isEmpty() && !Server_Room::isMainChannel(channelId) && room->findChannel(channelId) == nullptr) { + // The receiver does not know this channel; say() would drop it, so do + // not log what was never delivered. + qDebug() << "externalRoomSay: unknown channel" << channelId << "for room id=" << roomId << "dropping"; + return; + } + room->say(userName, message, channelId, false); - getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom, - room->getId(), room->getName()); + if (channelId.isEmpty()) { + getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom, + room->getId(), room->getName()); + } else { + getDatabaseInterface()->logMessage(0, userName, "ISL", message, + Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(), + room->getName(), channelId); + } } void Server::externalRoomRemoveMessages(int roomId, const QString &userName, int amount) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 932a136ff..8e4e1a8f1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -265,7 +265,7 @@ protected slots: void externalUserLeft(const QString &userName); void externalRoomUserJoined(int roomId, const ServerInfo_User &userInfo); void externalRoomUserLeft(int roomId, const QString &userName); - void externalRoomSay(int roomId, const QString &userName, const QString &message); + void externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId); void externalRoomRemoveMessages(int roomId, const QString &userName, int amount); void externalRoomGameListChanged(int roomId, const ServerInfo_Game &gameInfo); void diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index a78ee9e72..8733c9a40 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -383,7 +384,14 @@ void IslInterface::roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event) void IslInterface::roomEvent_Say(int roomId, const Event_RoomSay &event) { - emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message())); + emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()), + QString()); +} + +void IslInterface::roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event) +{ + emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()), + QString::fromStdString(event.channel_id())); } void IslInterface::roomEvent_ListGames(int roomId, const Event_ListGames &event) @@ -459,6 +467,9 @@ void IslInterface::processRoomEvent(const RoomEvent &event) case RoomEvent::ROOM_SAY: roomEvent_Say(event.room_id(), event.GetExtension(Event_RoomSay::ext)); break; + case RoomEvent::ROOM_CHANNEL_SAY: + roomEvent_ChannelSay(event.room_id(), event.GetExtension(Event_RoomChannelSay::ext)); + break; case RoomEvent::LIST_GAMES: roomEvent_ListGames(event.room_id(), event.GetExtension(Event_ListGames::ext)); break; diff --git a/servatrice/src/isl_interface.h b/servatrice/src/isl_interface.h index f7680c0a2..c1d9334a5 100644 --- a/servatrice/src/isl_interface.h +++ b/servatrice/src/isl_interface.h @@ -26,6 +26,7 @@ class Event_UserLeft; class Event_JoinRoom; class Event_LeaveRoom; class Event_RoomSay; +class Event_RoomChannelSay; class Event_ListGames; class Event_RemoveMessages; class Command_JoinGame; @@ -49,7 +50,7 @@ signals: void externalUserLeft(QString userName); void externalRoomUserJoined(int roomId, ServerInfo_User userInfo); void externalRoomUserLeft(int roomId, QString userName); - void externalRoomSay(int roomId, QString userName, QString message); + void externalRoomSay(int roomId, QString userName, QString message, QString channelId); void externalRoomGameListChanged(int roomId, ServerInfo_Game gameInfo); void externalRoomRemoveMessages(int roomId, QString userName, int amount); void joinGameCommandReceived(const Command_JoinGame &cmd, int cmdId, int roomId, int serverId, qint64 sessionId); @@ -79,6 +80,7 @@ private: void roomEvent_UserJoined(int roomId, const Event_JoinRoom &event); void roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event); void roomEvent_Say(int roomId, const Event_RoomSay &event); + void roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event); void roomEvent_ListGames(int roomId, const Event_ListGames &event); void roomEvent_RemoveMessages(int roomId, const Event_RemoveMessages &event); diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 040030320..a03444bf5 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -1078,8 +1078,8 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface) connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this, SLOT(externalRoomUserJoined(int, ServerInfo_User))); connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString))); - connect(interface, SIGNAL(externalRoomSay(int, QString, QString)), this, - SLOT(externalRoomSay(int, QString, QString))); + connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this, + SLOT(externalRoomSay(int, QString, QString, QString))); connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this, SLOT(externalRoomRemoveMessages(int, QString, int))); connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this, From a139084ad628f4273f9fd4b00dfe5bdd6f9d5951 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:52:26 +0200 Subject: [PATCH 045/247] [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords (#7193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords Challenge-response authentication: the client derives a scrypt verifier (RFC 7914, EVP_PBE_scrypt, N=32768, r=8, p=1) and authenticates with HMAC-SHA256(key, nonce), so neither the password nor its hash is transmitted. The stored format becomes "$scrypt$$$

$$" and Response_PasswordSalt now carries the cost parameters. Strict servers only accept scrypt verifiers; legacy accounts are migrated after a successful login. Fix #344 for challenge-response servers: a saved profile stores the derived verifier under the password key instead of the plaintext password. The connect dialog loads it without revealing it, autoconnect passes it through, the change-password dialog no longer prefills the old password field with it, and the client only persists the verifier when "Save password" is checked. Took 3 minutes Took 1 minute Took 10 seconds Took 7 minutes * [Security] Harden credential format acceptance and challenge nonce validation * [Security] Refuse KDF downgrade; trim profile name and warn on silent credential drop * [Security] Add SupportsAll ServerOptions enumerator to fix enum validation assert The server advertised both SupportsPasswordHash (1) and SupportsChallengeResponseAuth (2) via a bitwise-OR that produced the value 3, which no enumerator declared. Protobuf's proto2 enum validation rejects the value in _internal_set_server_options, crashing servatrice on the debug assertion. Add a SupportsAll = 3 enumerator with an identical wire value so the bitmask reads unchanged while the value is now a documented, valid member of the enum. * [Security] Normalize username when issuing challenge nonce cmdRequestPasswordSalt bound the challenge nonce to the raw, un-simplified username while cmdLogin verifies it against the simplified name. A user who typed leading or trailing whitespace in the username box would be silently rejected during challenge-response login even with the correct password. Simplify at the nonce-issuing site so both commands agree on the normalized name. Also drive-by formatting: collapse the now single-expression serverOptions initializer onto one line. * [Security] Keep an encrypted legacy password backup for old-client logins Migrating an account to scrypt stranded pre-challenge clients, which can only present the legacy salt+hash: after migration the server served them a scrypt salt they cannot use, so the only recovery was a password reset. When security/legacy_backup_key is configured (64 hex chars, openssl rand -hex 32), submitPasswordVerifier now seals the legacy credential with AES-256-GCM under that key before overwriting the row, storing it in password_legacy_backup with the time it was last used. Old clients keep working: cmdRequestPasswordSalt serves the backup's 16-char salt in password_salt so they can derive the legacy hash they were always able to compute, while new clients derive the verifier from the new scrypt_salt field, which always carries the verifier salt. The non-challenge login path accepts the plaintext or legacy hash against the decrypted backup and refreshes its window; a migrated row with no usable backup fails closed. Backups age out so a database dump taken after the window closes never carries a crackable legacy hash: getUsableLegacyBackup purges on first use past security/legacy_backup_ttl_days (default 90, clamped 1..3650), and a six-hourly sweep drops backups idle accounts never touch. changeUserPassword clears the backup since a new credential invalidates it. Without a configured key nothing is sealed or purged, so a temporarily missing key cannot destroy existing backups. The AAD is the simplified user name on both sides, sealing the blob to its row so it cannot be moved between accounts. Sealing failures degrade to the previous lockout behaviour rather than blocking migration. The columns extend the unmerged 0036->0037 migration, so the schema version stays 37. * [Security] Address review: proto compat, plaintext verify, AAD case, strict scope Fix the two blockers and follow-ups from the 2026-10-16 review: - Advertise challenge-response in a new supports_challenge_response field instead of the ServerOptions enum and drop SupportsAll: proto2 enums are closed, so already-released clients parsed server_options = 3 as NoOptions and fell back to plaintext passwords. server_options stays exactly SupportsPasswordHash on the wire; old clients ignore the new field. - Verify a plaintext password directly against a migrated scrypt row with verifyPassword() so email changes (cmdAccountEdit) and old clients sending password keep working without a legacy backup key. Pre-hashed values are still checked against the backup, and its TTL window is refreshed only when the backup is what actually matched. - Use the stored row name as the AES-GCM AAD on both seal and open so a login whose case differs from the stored name (utf8mb4_unicode_ci) still opens its backup. - Require challenge-response in strict mode only for registered SQL accounts (userExists gate), leaving guest/unregistered logins and none/password auth methods unaffected. - Document in servatrice.ini.example that a captured legacy hash can take over a not-yet-migrated account and that strict only constrains registered SQL accounts. Schema version is 38. * [Security] Raise legacy backup TTL default to 180 days Gives operators and slow-to-upgrade users a longer downgrade window for pre-challenge clients before unused encrypted legacy password backups age out. * [Security] Fix servatrice.cpp formatting per clang-format-18 * [Security] Add missing forward declaration for Command_SubmitPasswordVerifier The cmdSubmitPasswordVerifier declaration references the proto-generated type, which otherwise does not name a type in this header and breaks every C++ build. --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../remote_connection_controller.cpp | 21 +- .../remote_connection_controller.h | 16 +- .../interface/widgets/dialogs/dlg_connect.cpp | 18 +- .../interface/widgets/dialogs/dlg_connect.h | 18 ++ .../widgets/dialogs/dlg_edit_password.cpp | 5 +- .../widgets/server/user/user_info_box.cpp | 4 +- cockatrice/src/interface/window_main.cpp | 3 +- .../client/abstract/abstract_client.cpp | 3 +- .../network/client/abstract/abstract_client.h | 5 + .../network/client/remote/remote_client.cpp | 128 +++++++- .../network/client/remote/remote_client.h | 21 ++ .../network/server/remote/server.h | 5 + .../server/remote/server_database_interface.h | 8 + .../server/remote/server_protocolhandler.cpp | 39 +++ .../server/remote/server_protocolhandler.h | 12 + .../libcockatrice/protocol/featureset.cpp | 3 +- .../pb/event_server_identification.proto | 6 + .../protocol/pb/response_password_salt.proto | 18 ++ .../protocol/pb/session_commands.proto | 12 + .../settings/servers_settings.cpp | 13 + .../libcockatrice/settings/servers_settings.h | 2 + .../libcockatrice/utility/cryptoutil.cpp | 103 ++++++ .../libcockatrice/utility/cryptoutil.h | 9 + .../libcockatrice/utility/passwordhasher.cpp | 114 +++++++ .../libcockatrice/utility/passwordhasher.h | 44 +++ .../migrations/servatrice_0039_to_0040.sql | 17 + servatrice/servatrice.ini.example | 42 +++ servatrice/servatrice.sql | 6 +- servatrice/src/servatrice.cpp | 53 +++ servatrice/src/servatrice.h | 17 + .../src/servatrice_database_interface.cpp | 303 +++++++++++++++++- .../src/servatrice_database_interface.h | 14 +- servatrice/src/serversocketinterface.cpp | 131 +++++++- servatrice/src/serversocketinterface.h | 3 + tests/password_hash_test.cpp | 200 ++++++++++++ 35 files changed, 1385 insertions(+), 31 deletions(-) create mode 100644 servatrice/migrations/servatrice_0039_to_0040.sql 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 9b985073d..037565c6a 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -95,6 +95,9 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this, &ConnectionController::onPromptForgotPasswordChallenge); + + connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this, + &ConnectionController::onPasswordVerifierReady); } void ConnectionController::connectToServer() @@ -103,16 +106,32 @@ void ConnectionController::connectToServer() connect(dlgConnect, &DlgConnect::sigStartForgotPasswordRequest, this, &ConnectionController::forgotPasswordRequest); if (dlgConnect->exec()) { + pendingSaveName = dlgConnect->getSaveName(); + pendingSavePassword = dlgConnect->getSavePassword(); + remoteClient->setStoredVerifier(dlgConnect->getStoredVerifier()); remoteClient->connectToServer(dlgConnect->getHost(), static_cast(dlgConnect->getPort()), dlgConnect->getPlayerName(), dlgConnect->getPassword()); } } +void ConnectionController::onPasswordVerifierReady(const QString &verifier) +{ + if (pendingSavePassword) { + SettingsCache::instance().servers().setServerPassword(pendingSaveName, verifier); + } +} + void ConnectionController::connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, - const QString &password) + const QString &password, + const QString &storedVerifier, + const QString &saveName, + bool savePassword) { + pendingSaveName = saveName; + pendingSavePassword = savePassword; + remoteClient->setStoredVerifier(storedVerifier); remoteClient->connectToServer(host, port, playerName, password); } 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 d4a832c75..150ec9e5b 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -40,8 +40,13 @@ public: void registerToServer(); void forgotPasswordRequest(); void connectToServer(); - void - connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, const QString &password); + void connectToServerDirect(const QString &host, + unsigned int port, + const QString &playerName, + const QString &password, + const QString &storedVerifier = QString(), + const QString &saveName = QString(), + bool savePassword = false); void disconnectFromServer(); void refreshWindowTitle() @@ -86,6 +91,9 @@ private slots: void onPromptForgotPasswordReset(); void onPromptForgotPasswordChallenge(); + // Persists the derived scrypt verifier after a successful challenge-response login + void onPasswordVerifierReady(const QString &verifier); + private: void wireClientSignals(); void updateWindowTitle(); @@ -102,6 +110,10 @@ private: // Kept as a member so the forgot-password signal can be wired to it DlgConnect *dlgConnect{nullptr}; + + // Captured from the connect dialog when a connection is initiated + QString pendingSaveName; + bool pendingSavePassword{false}; }; #endif // COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 7a7e31a58..43b40bf30 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -289,9 +289,15 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) playernameEdit->setText(_data.at(3)); playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); + storedVerifier.clear(); if (savePasswordStatus) { - passwordEdit->setText(_data.at(4)); + const QString stored = _data.at(4); + if (stored.startsWith("$")) { + storedVerifier = stored; + } else { + passwordEdit->setText(stored); + } } if (!_data.at(6).isEmpty()) { @@ -317,6 +323,7 @@ void DlgConnect::newHostSelected(bool state) portEdit->setDisabled(false); playernameEdit->clear(); passwordEdit->clear(); + storedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); saveEdit->setDisabled(false); @@ -342,6 +349,11 @@ void DlgConnect::actOk() { ServersSettings &servers = SettingsCache::instance().servers(); + // Never write a newly typed plaintext password to disk when a verifier is already stored: + // the typed value is used for this connection and a fresh verifier is persisted after a + // successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior. + const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier; + if (newHostButton->isChecked()) { if (saveEdit->text().isEmpty()) { QMessageBox::critical(this, tr("Connection Warning"), tr("You need to name your new connection profile.")); @@ -349,10 +361,10 @@ void DlgConnect::actOk() } servers.addNewServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(), - playernameEdit->text().trimmed(), passwordEdit->text(), savePasswordCheckBox->isChecked()); + playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked()); } else { servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(), - playernameEdit->text().trimmed(), passwordEdit->text(), + playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked()); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index 10cb5366a..bef5f136e 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -7,6 +7,7 @@ #ifndef DLG_CONNECT_H #define DLG_CONNECT_H +#include #include #include #include @@ -51,6 +52,22 @@ public: { return passwordEdit->text(); } + //! \brief Stored "$scrypt$..." verifier for challenge-response servers (never the plaintext password). + [[nodiscard]] QString getStoredVerifier() const + { + return storedVerifier; + } + [[nodiscard]] QString getSaveName() const + { + // Trim so the profile lookup key always matches the name DlgConnect::actOk + // stored (it saves under saveEdit->text().trimmed()); otherwise a trailing + // space here misses the index and silently drops the saved verifier. + return saveEdit->text().trimmed(); + } + [[nodiscard]] bool getSavePassword() const + { + return savePasswordCheckBox->isChecked(); + } public slots: void downloadThePublicServers(); @@ -81,6 +98,7 @@ private: QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer; QMap> savedHostList; HandlePublicServers *hps; + QString storedVerifier; const QString placeHolderText = tr("Downloading..."); }; #endif diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp index 59f80c79c..979a4519c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp @@ -23,7 +23,10 @@ DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent) auto &servers = SettingsCache::instance().servers(); if (servers.getSavePassword()) { - oldPasswordEdit->setText(servers.getPassword()); + const QString stored = servers.getPassword(); + if (!stored.startsWith("$")) { + oldPasswordEdit->setText(stored); + } } oldPasswordLabel->setBuddy(oldPasswordEdit); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index ff3d9a224..60bc97edc 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -301,7 +301,9 @@ void UserInfoBox::changePassword(const QString &oldPassword, const QString &newP { Command_AccountPassword cmd; cmd.set_old_password(oldPassword.toStdString()); - if (client->getServerSupportsPasswordHash()) { + if (client->getServerSupportsChallengeResponse()) { + cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); + } else if (client->getServerSupportsPasswordHash()) { auto passwordSalt = PasswordHasher::generateRandomSalt(); QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt); cmd.set_hashed_new_password(hashedPassword.toStdString()); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 76092e765..d23ce254c 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -1000,7 +1000,8 @@ void MainWindow::attemptStartupAutoConnect() qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; DlgConnect dlg(this); connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword()); + dlg.getPlayerName(), dlg.getPassword(), dlg.getStoredVerifier(), + dlg.getSaveName(), dlg.getSavePassword()); } } diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index d66653fb1..0c523d776 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -41,7 +41,8 @@ #include AbstractClient::AbstractClient(QObject *parent) - : QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false) + : QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false), + serverSupportsChallengeResponse(false) { qRegisterMetaType("QVariant"); qRegisterMetaType("CommandContainer"); diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 442e96eb2..7efd67972 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -133,6 +133,7 @@ protected: QMap pendingCommands; QString userName, password, email, country, realName, token; bool serverSupportsPasswordHash; + bool serverSupportsChallengeResponse; void setStatus(ClientStatus _status); int getNewCmdId() { @@ -166,6 +167,10 @@ public: { return serverSupportsPasswordHash; } + bool getServerSupportsChallengeResponse() const + { + return serverSupportsChallengeResponse; + } const QString &getUserName() const { return userName; diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index feda9e3f4..cbf7dbda2 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -42,7 +42,8 @@ static const unsigned int protocolVersion = 14; RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider) : AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0), - messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword() + messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword(), + passwordNeedsMigration(false) { clearNewClientFeatures(); @@ -129,6 +130,11 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica return; } serverSupportsPasswordHash = event.server_options() & Event_ServerIdentification::SupportsPasswordHash; + // Challenge-response is advertised in its own field so server_options stays + // exactly SupportsPasswordHash on the wire for already-released clients + // (proto2 enums are closed: an unknown value parses as NoOptions and those + // clients would fall back to plaintext passwords). + serverSupportsChallengeResponse = event.has_supports_challenge_response() && event.supports_challenge_response(); if (getStatus() == StatusRequestingForgotPassword) { Command_ForgotPasswordRequest cmdForgotPasswordRequest; @@ -145,7 +151,13 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica cmdForgotPasswordReset.set_user_name(userName.toStdString()); cmdForgotPasswordReset.set_clientid(getSrvClientID(lastHostname).toStdString()); cmdForgotPasswordReset.set_token(token.toStdString()); - if (!password.isEmpty() && serverSupportsPasswordHash) { + if (!password.isEmpty() && serverSupportsChallengeResponse) { + hashedPassword = PasswordHasher::generatePasswordVerifier(password); + // Only this branch yields a challenge-response verifier worth persisting; the + // legacy-hash and plaintext branches below must not be saved under the password key. + derivedVerifier = hashedPassword; + cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString()); + } else if (!password.isEmpty() && serverSupportsPasswordHash) { auto passwordSalt = PasswordHasher::generateRandomSalt(); hashedPassword = PasswordHasher::computeHash(password, passwordSalt); cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString()); @@ -173,7 +185,10 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica if (getStatus() == StatusRegistering) { Command_Register cmdRegister; cmdRegister.set_user_name(userName.toStdString()); - if (!password.isEmpty() && serverSupportsPasswordHash) { + if (!password.isEmpty() && serverSupportsChallengeResponse) { + hashedPassword = PasswordHasher::generatePasswordVerifier(password); + cmdRegister.set_hashed_password(hashedPassword.toStdString()); + } else if (!password.isEmpty() && serverSupportsPasswordHash) { auto passwordSalt = PasswordHasher::generateRandomSalt(); hashedPassword = PasswordHasher::computeHash(password, passwordSalt); cmdRegister.set_hashed_password(hashedPassword.toStdString()); @@ -238,7 +253,9 @@ Command_Login RemoteClient::generateCommandLogin() void RemoteClient::doLogin() { - if (!password.isEmpty() && serverSupportsPasswordHash) { + if ((!password.isEmpty() || !storedVerifier.isEmpty()) && serverSupportsChallengeResponse) { + doRequestPasswordSalt(); // ask salt + nonce to build the challenge response + } else if (!password.isEmpty() && serverSupportsPasswordHash) { //! \todo Store and log in using stored hashed password. if (hashedPassword.isEmpty()) { doRequestPasswordSalt(); // ask salt to create hashedPassword, then log in @@ -272,6 +289,30 @@ void RemoteClient::doHashedLogin() sendCommand(pend); } +void RemoteClient::doSubmitPasswordVerifier() +{ + pendingVerifier = PasswordHasher::generatePasswordVerifier(password); + Command_SubmitPasswordVerifier cmdSubmitVerifier; + cmdSubmitVerifier.set_password_verifier(pendingVerifier.toStdString()); + + PendingCommand *pend = prepareSessionCommand(cmdSubmitVerifier); + connect(pend, &PendingCommand::finished, this, &RemoteClient::submitPasswordVerifierResponse); + sendCommand(pend); +} + +void RemoteClient::submitPasswordVerifierResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + qCDebug(RemoteClientLog) << "Password verifier migrated successfully"; + if (!pendingVerifier.isEmpty()) { + emit sigPasswordVerifierReady(pendingVerifier); + pendingVerifier.clear(); + } + } else { + qCWarning(RemoteClientLog) << "Failed to migrate password verifier:" << response.response_code(); + } +} + void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/) { doDisconnectFromServer(); @@ -284,7 +325,70 @@ void RemoteClient::passwordSaltResponse(const Response &response) auto passwordSalt = QString::fromStdString(resp.password_salt()); if (passwordSalt.isEmpty()) { // the server does not recognize the user but allows them to enter unregistered password.clear(); // the password will not be used + storedVerifier.clear(); doLogin(); + } else if (serverSupportsChallengeResponse && resp.has_nonce()) { + const QByteArray nonce = QByteArray::fromStdString(resp.nonce()); + // needs_migration is a server-controlled flag over an unauthenticated + // transport (plain TCP by default). If we already hold a scrypt + // verifier for this account, a server telling us to fall back to the + // 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, + {}); + return; + } + QByteArray key; + if (resp.needs_migration()) { + // 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, {}); + return; + } + key = PasswordHasher::computeHash(password, passwordSalt).toUtf8(); + } else if (!password.isEmpty()) { + // A hostile server must not be able to make us run or allocate for + // unreasonable scrypt parameters. + const int n = resp.has_n() ? resp.n() : SCRYPT_N; + 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, {}); + return; + } + // password_salt may carry a legacy backup salt for pre-challenge clients; + // the verifier itself is always derived from scrypt_salt when present. + const QString scryptSalt = + 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, + {}); + return; + } + derivedVerifier = + QString("$scrypt$%1$%2$%3$%4$%5").arg(n).arg(r).arg(p).arg(scryptSalt).arg(QString(key.toBase64())); + } else if (!storedVerifier.isEmpty()) { + const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedVerifier); + if (!verifier.isValid) { + emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Stored verifier is invalid."), + 0, {}); + return; + } + key = verifier.verifier; + } else { + emit loginError(Response::RespLoginNeeded, {}, 0, {}); + return; + } + passwordNeedsMigration = resp.needs_migration(); + const QByteArray responseBytes = PasswordHasher::computeResponse(key, nonce); + hashedPassword = "$challenge$" + QString(nonce.toBase64()) + "$" + QString(responseBytes.toBase64()); + doHashedLogin(); } else { hashedPassword = PasswordHasher::computeHash(password, passwordSalt); doHashedLogin(); @@ -309,6 +413,14 @@ void RemoteClient::loginResponse(const Response &response) setStatus(StatusLoggedIn); emit userInfoChanged(resp.user_info()); + if (passwordNeedsMigration) { + // The account still used the legacy password format; upgrade it to scrypt. + doSubmitPasswordVerifier(); + } else if (!derivedVerifier.isEmpty()) { + emit sigPasswordVerifierReady(derivedVerifier); + derivedVerifier.clear(); + } + QList buddyList; for (int i = resp.buddy_list_size() - 1; i >= 0; --i) { buddyList.append(resp.buddy_list(i)); @@ -565,6 +677,8 @@ void RemoteClient::doDisconnectFromServer() websocket->close(); } socket->close(); + derivedVerifier.clear(); + pendingVerifier.clear(); } void RemoteClient::ping() @@ -736,6 +850,12 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response) { if (response.response_code() == Response::RespOk) { emit sigForgotPasswordSuccess(); + // Persist only a real scrypt verifier; a legacy hash must not be stored under + // the password key, where it would break future challenge-response logins. + if (!derivedVerifier.isEmpty()) { + emit sigPasswordVerifierReady(derivedVerifier); + derivedVerifier.clear(); + } } else { emit sigForgotPasswordError(); } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index 4d8c24fd2..dd91c4c6d 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -66,6 +66,11 @@ signals: unsigned int port, const QString &_userName, const QString &_email); + //! \brief Emitted once a scrypt verifier for the given account is known and + //! can be persisted instead of the plaintext password. The receiving side + //! should only store it for the connection it is currently negotiating, so + //! the hostname and user name are intentionally not part of the signal. + void sigPasswordVerifierReady(const QString &verifier); private slots: void slotConnected(); void readData(); @@ -92,6 +97,8 @@ private slots: void doLogin(); void doHashedLogin(); Command_Login generateCommandLogin(); + void doSubmitPasswordVerifier(); + void submitPasswordVerifierResponse(const Response &response); void doDisconnectFromServer(); void doActivateToServer(const QString &_token); void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); @@ -123,6 +130,14 @@ private: QString lastHostname; unsigned int lastPort; QString hashedPassword; + bool passwordNeedsMigration; + //! \brief A previously stored "$scrypt$..." verifier used to authenticate + //! without the plaintext password. + QString storedVerifier; + //! \brief Verifier derived during the current login, persisted after success. + QString derivedVerifier; + //! \brief Verifier sent for migration, persisted once the server accepts it. + QString pendingVerifier; QString getSrvClientID(const QString &_hostname); bool newMissingFeatureFound(const QString &_serversMissingFeatures); @@ -161,6 +176,12 @@ public: } void connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password); + //! \brief Provide a stored "$scrypt$..." verifier so the client can + //! authenticate without the plaintext password. + void setStoredVerifier(const QString &verifier) + { + storedVerifier = verifier; + } void registerToServer(const QString &hostname, unsigned int port, const QString &_userName, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 8e4e1a8f1..9e05750ac 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -87,6 +87,11 @@ public: { return QMap(); } + /** @brief True when only challenge-response logins are accepted (strict mode). */ + virtual bool requiresChallengeResponseAuth() const + { + return false; + } void addClient(Server_ProtocolHandler *player); void removeClient(Server_ProtocolHandler *player); QList getOnlineModeratorList() const; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index 3fef26904..936c48880 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -53,6 +53,14 @@ public: { return {}; } + virtual QString getUserPasswordData(const QString & /* user */) + { + return {}; + } + virtual bool submitPasswordVerifier(const QString & /* user */, const QString & /* passwordVerifier */) + { + return false; + } virtual QMap getBuddyList(const QString & /* name */) { return QMap(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index f75e2d3e3..eb2b2fc1c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1,5 +1,8 @@ #include "server_protocolhandler.h" +// Challenge-response nonces are valid for at most one minute from issuance. +static constexpr qint64 kAuthNonceLifetimeSeconds = 60; + #include "game/game_config.h" #include "game/server_abstract_participant.h" #include "game/server_game.h" @@ -72,6 +75,28 @@ Server_ProtocolHandler::~Server_ProtocolHandler() { } +void Server_ProtocolHandler::setAuthNonce(const QByteArray &nonce, const QString &userName) +{ + authNonce = nonce; + authNonceUser = userName; + authNonceCreated = QDateTime::currentDateTimeUtc(); +} + +bool Server_ProtocolHandler::isAuthNonceValid(const QByteArray &nonce, const QString &userName) const +{ + // secsTo is signed, so a wall-clock step backwards (NTP correction, VM resume) + // must not make the elapsed time negative and re-validate an old nonce. + const qint64 elapsed = authNonceCreated.secsTo(QDateTime::currentDateTimeUtc()); + return !authNonce.isEmpty() && authNonce == nonce && authNonceUser == userName && authNonceCreated.isValid() && + elapsed >= 0 && elapsed < kAuthNonceLifetimeSeconds; +} + +void Server_ProtocolHandler::clearAuthNonce() +{ + authNonce.clear(); + authNonceUser.clear(); +} + // This function must only be called from the thread this object lives in. // Except when the server is shutting down. // The thread must not hold any server locks when calling this (e.g. clientsLock, roomsLock). @@ -568,6 +593,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd return Response::RespContextError; } + // In strict mode only challenge-response logins are accepted, but only for + // registered SQL accounts: guests (no DB row) and non-SQL authentication + // methods have no stored verifier to challenge against. userExists is false + // for both (it requires AuthenticationSql), so a single check covers them. + const bool isRegisteredAccount = databaseInterface != nullptr && databaseInterface->userExists(userName); + if (server->requiresChallengeResponseAuth() && isRegisteredAccount && + (!cmd.has_hashed_password() || cmd.hashed_password().rfind("$challenge$", 0) != 0)) { + auto *re = new Response_Login; + re->set_denied_reason_str("Client upgrade required"); + re->add_missing_features("challenge_response_auth"); + rc.setResponseExtension(re); + return Response::RespClientUpdateRequired; + } + // check client feature set against server feature set FeatureSet features; QMap receivedClientFeatures; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 65dab99ed..c0a1ffe9a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -4,6 +4,8 @@ #include "server.h" #include "server_abstractuserinterface.h" +#include +#include #include #include #include @@ -47,6 +49,9 @@ protected: bool acceptsUserListChanges; bool acceptsRoomListChanges; bool idleClientWarningSent; + QByteArray authNonce; + QString authNonceUser; + QDateTime authNonceCreated; virtual void logDebugMessage(const QString & /* message */) { } @@ -124,6 +129,13 @@ public: return databaseInterface; } + /** @brief Store a fresh challenge nonce bound to @p userName for the next challenge-response login attempt. */ + void setAuthNonce(const QByteArray &nonce, const QString &userName); + /** @brief True if nonce matches the pending one, was issued for @p userName, and is less than 60 seconds old. */ + bool isAuthNonceValid(const QByteArray &nonce, const QString &userName) const; + /** @brief Invalidate the pending nonce (single-use). */ + void clearAuthNonce(); + int getLastCommandTime() const { return timeRunning - lastDataReceived; diff --git a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp index 0f8fd4210..b0d67c00d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp @@ -26,7 +26,8 @@ void FeatureSet::initalizeFeatureList(QMap &_featureList) _featureList.insert("idle_client", false); _featureList.insert("forgot_password", false); _featureList.insert("websocket", false); - // featureList.insert("hashed_password_login", false); + _featureList.insert("hashed_password_login", false); + _featureList.insert("challenge_response_auth", false); // These are temp to force users onto a newer client _featureList.insert("2.7.0_min_version", false); _featureList.insert("2.8.0_min_version", false); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto index 987ab20d1..1f1775063 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto @@ -13,4 +13,10 @@ message Event_ServerIdentification { optional string server_version = 2; optional uint32 protocol_version = 3; optional ServerOptions server_options = 4 [default = NoOptions]; + // Advertises challenge-response authentication separately from server_options. + // server_options must stay exactly SupportsPasswordHash on the wire: it is a + // proto2 enum (closed), so already-released clients compiled without any new + // enumerator parse an unknown value as NoOptions and fall back to sending + // the plaintext password. New clients check this field instead. + optional bool supports_challenge_response = 5; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto index 3fc228530..aeb3eade2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto @@ -5,5 +5,23 @@ message Response_PasswordSalt { extend Response { optional Response_PasswordSalt ext = 1017; } + // Salt for the legacy password hash. Pre-challenge clients derive and send + // that hash from it. For migrated accounts holding a fresh legacy backup + // this carries the backup's salt so those clients can still log in; clients + // that speak challenge-response must use scrypt_salt instead when present. optional string password_salt = 1; + // scrypt cost parameters for the verifier. Absent/zero for legacy accounts. + optional int32 n = 2; + optional int32 r = 3; + optional int32 p = 4; + // Server-generated challenge. When present the client must authenticate + // with a challenge-response instead of transmitting the password hash. + optional bytes nonce = 5; + // True when the account still uses the legacy password format and should + // be migrated to the scrypt format after a successful login. + optional bool needs_migration = 6; + // Salt of the stored scrypt verifier. Present for migrated accounts; + // challenge-response clients derive the verifier from this rather than + // from password_salt, which may carry a legacy backup salt. + optional string scrypt_salt = 7; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 4b7fe9c85..10aefe7b5 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -36,6 +36,7 @@ message SessionCommand { DECK_DOWNLOAD_PUBLIC = 1031; DECK_SHARE_LIST_MINE = 1032; DECK_SHARE_REMOVE = 1033; + SUBMIT_PASSWORD_VERIFIER = 1034; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; @@ -231,3 +232,14 @@ message Command_SetCardArtParams { optional double vertical_offset = 5; optional double zoom = 6; } + +// Client uploads the new password verifier to migrate a legacy account +// after a successful challenge-response login. Idempotent; only applies +// to accounts still using the legacy password format. +message Command_SubmitPasswordVerifier { + extend SessionCommand { + optional Command_SubmitPasswordVerifier ext = 1034; + } + // Full verifier string to store, e.g. "$scrypt$32768$8$1$$" + required string password_verifier = 1; +} diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index c5c4b2592..4595da2a2 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -155,6 +155,19 @@ void ServersSettings::setFPPlayerName(QString playerName) setValue(playerName, "fpPlayerName"); } +void ServersSettings::setServerPassword(const QString &saveName, const QString &password) +{ + const int index = getPrevioushostindex(saveName); + if (index >= 0) { + setValue(password, QString("password%1").arg(index), "server", "server_details"); + } else { + // A credential write that silently no-ops is hard to diagnose from a + // bug report; surface a mismatched profile name instead. + qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName + << "- password not saved"; + } +} + QString ServersSettings::getFPPlayerName(QString defaultName) const { QVariant name = getValue("fpPlayerName"); diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 40648baa9..38bb02876 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -51,6 +51,8 @@ public: void setFPHostName(QString hostname); void setFPPort(QString port); void setFPPlayerName(QString playerName); + //! \brief Store a password (or a "$scrypt$..." verifier) for the given saved server. + void setServerPassword(const QString &saveName, const QString &password); void addNewServer(const QString &saveName, const QString &serv, const QString &port, diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp index fb59022de..871ee1357 100644 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -1,8 +1,16 @@ #include "cryptoutil.h" +#include +#include #include #include +// Sealed secret layout: "enc1::". +static constexpr int SECRET_KEY_LENGTH = 32; +static constexpr int SECRET_NONCE_LENGTH = 12; +static constexpr int SECRET_TAG_LENGTH = 16; +static const char *SECRET_FORMAT_PREFIX = "enc1"; + namespace CryptoUtil { QByteArray randomBytes(int count) @@ -23,4 +31,99 @@ quint64 randomUInt64() } return value; } + +QString encryptSecret(const QByteArray &plaintext, const QByteArray &key, const QByteArray &aad) +{ + if (key.size() != SECRET_KEY_LENGTH) { + return {}; + } + const QByteArray nonce = randomBytes(SECRET_NONCE_LENGTH); + QByteArray ciphertext(plaintext.size() + SECRET_TAG_LENGTH, Qt::Uninitialized); + QByteArray tag(SECRET_TAG_LENGTH, Qt::Uninitialized); + + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + if (ctx == nullptr) { + return {}; + } + int written = 0; + int finalWritten = 0; + bool ok = EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, nonce.size(), nullptr) == 1 && + EVP_EncryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast(key.constData()), + reinterpret_cast(nonce.constData())) == 1; + if (ok && !aad.isEmpty()) { + ok = EVP_EncryptUpdate(ctx, nullptr, &written, reinterpret_cast(aad.constData()), + aad.size()) == 1; + } + if (ok) { + ok = EVP_EncryptUpdate(ctx, reinterpret_cast(ciphertext.data()), &written, + reinterpret_cast(plaintext.constData()), plaintext.size()) == 1; + } + if (ok) { + ok = EVP_EncryptFinal_ex(ctx, reinterpret_cast(ciphertext.data() + written), &finalWritten) == + 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, SECRET_TAG_LENGTH, tag.data()) == 1; + } + EVP_CIPHER_CTX_free(ctx); + if (!ok) { + return {}; + } + ciphertext.truncate(written + finalWritten); + ciphertext.append(tag); + + return QString::fromLatin1(SECRET_FORMAT_PREFIX) + QLatin1Char(':') + nonce.toBase64() + QLatin1Char(':') + + ciphertext.toBase64(); +} + +QByteArray decryptSecret(const QString &sealed, const QByteArray &key, const QByteArray &aad) +{ + if (key.size() != SECRET_KEY_LENGTH) { + return {}; + } + const QStringList parts = sealed.split(QLatin1Char(':')); + if (parts.size() != 3 || parts.at(0) != QLatin1String(SECRET_FORMAT_PREFIX)) { + return {}; + } + const QByteArray nonce = QByteArray::fromBase64(parts.at(1).toUtf8()); + const QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8()); + if (nonce.size() != SECRET_NONCE_LENGTH || ciphertext.size() < SECRET_TAG_LENGTH) { + return {}; + } + const int plaintextLength = ciphertext.size() - SECRET_TAG_LENGTH; + QByteArray plaintext(qMax(plaintextLength, 1), Qt::Uninitialized); + QByteArray tag = ciphertext.right(SECRET_TAG_LENGTH); + const QByteArray body = ciphertext.left(plaintextLength); + + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + if (ctx == nullptr) { + return {}; + } + int written = 0; + int finalWritten = 0; + bool ok = EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, nonce.size(), nullptr) == 1 && + EVP_DecryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast(key.constData()), + reinterpret_cast(nonce.constData())) == 1; + if (ok && !aad.isEmpty()) { + ok = EVP_DecryptUpdate(ctx, nullptr, &written, reinterpret_cast(aad.constData()), + aad.size()) == 1; + } + if (ok) { + // The tag must be set before the final call, which authenticates it; a + // mismatch makes EVP_DecryptFinal_ex fail and the secret stays opaque. + ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, tag.size(), tag.data()) == 1 && + EVP_DecryptUpdate(ctx, reinterpret_cast(plaintext.data()), &written, + reinterpret_cast(body.constData()), body.size()) == 1; + } + if (ok) { + ok = + EVP_DecryptFinal_ex(ctx, reinterpret_cast(plaintext.data() + written), &finalWritten) == 1; + } + EVP_CIPHER_CTX_free(ctx); + if (!ok) { + return {}; + } + plaintext.truncate(written + finalWritten); + return plaintext; +} } // namespace CryptoUtil diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h index dba9dc37d..d12200bc7 100644 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h @@ -2,12 +2,21 @@ #define CRYPTOUTIL_H #include +#include #include namespace CryptoUtil { QByteArray randomBytes(int count); quint64 randomUInt64(); +/** @brief Seal a secret with AES-256-GCM as "enc1::". + * Returns an empty string unless @p key is a valid 32-byte key and the cipher + * completes. @p aad is authenticated but not stored (bind the ciphertext to its + * context, e.g. the user name it belongs to). */ +QString encryptSecret(const QByteArray &plaintext, const QByteArray &key, const QByteArray &aad = {}); +/** @brief Open a string produced by encryptSecret(). Returns an empty array on any + * failure: unknown format, malformed base64, wrong key, wrong aad, or tampering. */ +QByteArray decryptSecret(const QString &sealed, const QByteArray &key, const QByteArray &aad = {}); } // namespace CryptoUtil #endif diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index b4a412fa6..a7106aadc 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -4,6 +4,9 @@ #include #include #include +#include +#include +#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -54,3 +57,114 @@ QString PasswordHasher::generateActivationToken() { return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); } + +QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p) +{ + QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0'); + const QByteArray passwordUtf8 = password.toUtf8(); + // EVP_PBE_scrypt aborts unless maxmem covers the required working memory, + // which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array). + const auto maxmem = static_cast(128) * n * r + static_cast(128) * r * p + 4096; + if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(), + reinterpret_cast(salt.constData()), salt.size(), n, r, p, maxmem, + reinterpret_cast(key.data()), key.size()) != 1) { + return QByteArray(); + } + return key; +} + +bool PasswordHasher::costParamsAreSane(int n, int r, int p) +{ + // Bounds adopted during review: n in [1024, 2**20] and a power of two, r in [1, 32], + // p in [1, 16]. Anything else is rejected before we allocate or derive for it. + return n >= 1024 && n <= (1 << 20) && (n & (n - 1)) == 0 && r >= 1 && r <= 32 && p >= 1 && p <= 16; +} + +QString PasswordHasher::generatePasswordVerifier(const QString &password) +{ + const QByteArray salt = CryptoUtil::randomBytes(SCRYPT_SALT_LENGTH); + const QByteArray verifier = deriveKey(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P); + return QString("$scrypt$%1$%2$%3$%4$%5") + .arg(SCRYPT_N) + .arg(SCRYPT_R) + .arg(SCRYPT_P) + .arg(QString(salt.toBase64())) + .arg(QString(verifier.toBase64())); +} + +PasswordVerifier PasswordHasher::parsePasswordVerifier(const QString &stored) +{ + PasswordVerifier result; + const QStringList parts = stored.split("$"); + if (parts.size() != 7 || parts.at(1) != "scrypt") { + return result; + } + + bool ok = false; + const int n = parts.at(2).toInt(&ok); + if (!ok) { + return result; + } + const int r = parts.at(3).toInt(&ok); + if (!ok) { + return result; + } + const int p = parts.at(4).toInt(&ok); + if (!ok || !costParamsAreSane(n, r, p)) { + return result; + } + + const QByteArray salt = QByteArray::fromBase64(parts.at(5).toUtf8()); + const QByteArray verifier = QByteArray::fromBase64(parts.at(6).toUtf8()); + if (salt.isEmpty() || verifier.size() != SCRYPT_VERIFIER_LENGTH) { + return result; + } + + result.format = PasswordFormat::Scrypt; + result.n = n; + result.r = r; + result.p = p; + result.salt = salt; + result.verifier = verifier; + result.isValid = true; + return result; +} + +bool PasswordHasher::isLegacyFormat(const QString &stored) +{ + return !stored.startsWith("$"); +} + +bool PasswordHasher::verifyPassword(const QString &password, const QString &storedPasswordData) +{ + if (isLegacyFormat(storedPasswordData)) { + return storedPasswordData == computeHash(password, storedPasswordData.left(16)); + } + + const PasswordVerifier verifier = parsePasswordVerifier(storedPasswordData); + if (!verifier.isValid) { + return false; + } + const QByteArray derived = deriveKey(password, verifier.salt, verifier.n, verifier.r, verifier.p); + return !derived.isEmpty() && constantTimeEquals(derived, verifier.verifier); +} + +QByteArray PasswordHasher::computeResponse(const QByteArray &key, const QByteArray &nonce) +{ + QByteArray response(EVP_MAX_MD_SIZE, '\0'); + unsigned int responseLength = 0; + if (HMAC(EVP_sha256(), key.constData(), key.size(), reinterpret_cast(nonce.constData()), + nonce.size(), reinterpret_cast(response.data()), &responseLength) == nullptr) { + qFatal("PasswordHasher::computeResponse: HMAC failed"); + } + response.resize(responseLength); + return response; +} + +bool PasswordHasher::constantTimeEquals(const QByteArray &a, const QByteArray &b) +{ + if (a.size() != b.size()) { + return false; + } + return CRYPTO_memcmp(a.constData(), b.constData(), a.size()) == 0; +} diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.h b/libcockatrice_utility/libcockatrice/utility/passwordhasher.h index 8ad83f089..f46d2f21a 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.h +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.h @@ -1,14 +1,58 @@ #ifndef PASSWORDHASHER_H #define PASSWORDHASHER_H +#include +#include #include +// scrypt cost parameters used for newly created password verifiers. These match +// the RFC 7914 recommended parameters for interactive use. +constexpr int SCRYPT_N = 32768; +constexpr int SCRYPT_R = 8; +constexpr int SCRYPT_P = 1; +constexpr int SCRYPT_SALT_LENGTH = 16; +constexpr int SCRYPT_VERIFIER_LENGTH = 64; + +enum class PasswordFormat +{ + None = 0, + Scrypt +}; + +struct PasswordVerifier +{ + PasswordFormat format = PasswordFormat::None; + int n = 0; + int r = 0; + int p = 0; + QByteArray salt; + QByteArray verifier; + bool isValid = false; +}; + class PasswordHasher { public: static QString computeHash(const QString &password, const QString &salt); static QString generateRandomSalt(const int len = 16); static QString generateActivationToken(); + + /** @brief Derive the scrypt verifier for the given password, salt and cost parameters. Empty on failure. */ + static QByteArray deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p); + /** @brief True if the scrypt cost parameters are acceptable for server and client use. */ + static bool costParamsAreSane(int n, int r, int p); + /** @brief Build a "$scrypt$$$

$$" string with a fresh random salt. */ + static QString generatePasswordVerifier(const QString &password); + /** @brief Parse a stored "$scrypt$..." string into its components. */ + static PasswordVerifier parsePasswordVerifier(const QString &stored); + /** @brief True if the stored value is not in the scrypt format (legacy salt+hash). */ + static bool isLegacyFormat(const QString &stored); + /** @brief True if the password matches the stored credential, whether legacy salt+hash or scrypt. */ + static bool verifyPassword(const QString &password, const QString &storedPasswordData); + /** @brief HMAC-SHA256 of nonce keyed with the password verifier, used for challenge-response logins. */ + static QByteArray computeResponse(const QByteArray &key, const QByteArray &nonce); + /** @brief Constant-time byte comparison. */ + static bool constantTimeEquals(const QByteArray &a, const QByteArray &b); }; #endif diff --git a/servatrice/migrations/servatrice_0039_to_0040.sql b/servatrice/migrations/servatrice_0039_to_0040.sql new file mode 100644 index 000000000..8298d03f9 --- /dev/null +++ b/servatrice/migrations/servatrice_0039_to_0040.sql @@ -0,0 +1,17 @@ +-- Servatrice db migration from version 39 to version 40 + +-- The column must hold "$scrypt$$$

$$" (up to ~255 chars) and arbitrary +-- legacy base64 hashes, so it grows beyond the old 120-char size. varchar(255) is used because +-- widening a CHAR requires a table rebuild, which ALGORITHM=INSTANT cannot perform — dropping the +-- clause lets the server pick a suitable algorithm (and any row-format change is avoided anyway). +ALTER TABLE `cockatrice_users` MODIFY `password_sha512` varchar(255) NOT NULL; + +-- Encrypted downgrade backup: lets pre-challenge clients keep logging in after the +-- account migrates to scrypt, without storing the fast-crackable legacy hash in the +-- clear. Sealed with security/legacy_backup_key and purged after +-- security/legacy_backup_ttl_days without use (see password_legacy_backup_used_at). +ALTER TABLE `cockatrice_users` + ADD COLUMN `password_legacy_backup` varchar(255) NOT NULL DEFAULT '', + ADD COLUMN `password_legacy_backup_used_at` datetime DEFAULT NULL; + +UPDATE cockatrice_schema_version SET version=40 WHERE version=39; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 90207aa70..68478810b 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -420,6 +420,48 @@ max_forgot_password_requests_per_ip=3 ; Length in seconds of the forgot-password window. Default is 3600 (1 hour). forgot_password_window_seconds=3600 +; How strictly new authentication features are enforced. Possible values: +; * legacy: accounts that still use the legacy 1000-round SHA-512 hash keep logging in with it +; (they are served the legacy salt, never a challenge-response nonce) and are never +; auto-migrated to scrypt. Already-migrated scrypt rows keep logging in via +; challenge-response. New credentials may use either format; +; * mixed: accept both legacy hashes and challenge-response authentication (default); +; * strict: only accept challenge-response authentication from clients that support it, +; and reject plain password submissions. Legacy accounts are migrated to scrypt +; automatically on their next successful login. Strict only constrains +; registered SQL accounts: guest/unregistered logins and servers with +; authentication/method set to none or password are unaffected, since +; they have no stored verifier to challenge against. +; +; Note: anyone holding only the legacy salt+hash of a not-yet-migrated account +; (e.g. from a wire capture of an old client, or an old database dump) can log in +; with it and then set a brand new password of their choosing. Before +; challenge-response a captured hash yielded a session, but changing the password +; still required the plaintext old password. There is no way around this without +; a plaintext proof at migration time, so not-yet-migrated rows should be treated +; accordingly. +; +; Challenge-response verifiers are scrypt (RFC 7914, N=32768, r=8, p=1) stored as +; "$scrypt$$$

$$". The stored verifier is password-equivalent: +; plaintext passwords never reach the client configuration and never go over the wire, but +; a database dump yields credentials that can answer a login challenge directly. +authentication_strictness=mixed + +; Optional key that seals the legacy password backup (64 hex characters). +; Migrating an account to scrypt normally strands old clients, which can only present +; the legacy hash. With a key configured, the server keeps that hash encrypted next to +; the scrypt verifier, so old clients can still log in during the window below without +; the database holding a fast-crackable hash in the clear. +; Generate with: openssl rand -hex 32 +; If unset, migrated accounts have no downgrade window: old clients must use a password +; reset to get back in. Rotating the key invalidates existing backups (they become +; unusable immediately; expired ones are still purged). +legacy_backup_key= + +; Days a legacy password backup may go unused before it is purged from the database. +; Logging in with an old client refreshes the window; backups that are never touched +; age out. Default 180. +legacy_backup_ttl_days=180 ; You may want to allow an unlimited number of users from a trusted source. This setting can contain a ; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 438b32ccd..e942e4e91 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(39); +INSERT INTO cockatrice_schema_version VALUES(40); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `admin` tinyint(1) NOT NULL, `name` varchar(35) NOT NULL, `realname` varchar(255) NOT NULL, - `password_sha512` char(120) NOT NULL, + `password_sha512` varchar(255) NOT NULL, + `password_legacy_backup` varchar(255) NOT NULL DEFAULT '', + `password_legacy_backup_used_at` datetime DEFAULT NULL, `email` varchar(255) NOT NULL, `country` char(2) NOT NULL, `avatar_bmp` mediumblob NOT NULL, diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index a03444bf5..a306de523 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -76,6 +77,10 @@ inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice"); +// Expired legacy password backups are also purged on first use, but idle accounts +// need this sweep so credentials that are never touched still age out of the database. +static constexpr qint64 kLegacyBackupSweepIntervalMs = 6 * 60 * 60 * 1000; + Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, int _numberPools, const QSqlDatabase &_sqlDatabase, @@ -613,6 +618,10 @@ bool Servatrice::initServer() deckShareCleanupClock->start(deckShareCleanupInterval); } + legacyBackupSweepClock = new QTimer(this); + connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups); + legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs); + // SOCKET SERVER if (getNumberOfTCPPools() > 0) { gameServer = @@ -1002,6 +1011,14 @@ SessionEvent *Servatrice::getLoginSessionEvent() const return nullptr; } +void Servatrice::sweepLegacyBackups() +{ + if (!servatriceDatabaseInterface->checkSql()) { + return; + } + servatriceDatabaseInterface->purgeExpiredLegacyBackups(); +} + void Servatrice::scheduleShutdown(const QString &reason, int minutes) { shutdownStateMutex.lock(); @@ -1194,6 +1211,42 @@ QString Servatrice::getRequiredFeatures() const return settingsCache->value("server/requiredfeatures", "").toString(); } +Servatrice::AuthenticationStrictness Servatrice::getAuthenticationStrictness() const +{ + const QString strictness = settingsCache->value("security/authentication_strictness", "mixed").toString(); + if (strictness == "strict") { + return AuthenticationStrict; + } + if (strictness == "legacy") { + return AuthenticationLegacy; + } + return AuthenticationMixed; +} + +QByteArray Servatrice::getLegacyBackupKey() const +{ + const QString keyHex = settingsCache->value("security/legacy_backup_key").toString().trimmed(); + if (keyHex.isEmpty()) { + return {}; + } + static const QRegularExpression validHexKey(QStringLiteral("^[0-9a-fA-F]{64}$")); + if (!validHexKey.match(keyHex).hasMatch()) { + qDebug() << "security/legacy_backup_key is not 64 hex characters;" << "legacy password backups are disabled"; + return {}; + } + return QByteArray::fromHex(keyHex.toUtf8()); +} + +int Servatrice::getLegacyBackupTtlDays() const +{ + bool ok = false; + const int days = settingsCache->value("security/legacy_backup_ttl_days", 180).toInt(&ok); + if (!ok || days < 1 || days > 3650) { + return 180; + } + return days; +} + QString Servatrice::getDBTypeString() const { if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) { diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index d5414f810..119adc8b2 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -155,8 +155,15 @@ public: AuthenticationSql, AuthenticationPassword }; + enum AuthenticationStrictness + { + AuthenticationLegacy, + AuthenticationMixed, + AuthenticationStrict + }; private slots: void statusUpdate(); + void sweepLegacyBackups(); void shutdownTimeout(); void cleanupExpiredDeckShares(); @@ -173,6 +180,7 @@ private: DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; QTimer *deckShareCleanupClock; + QTimer *legacyBackupSweepClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -251,6 +259,14 @@ public: { return serverRequiredFeatureList; } + bool requiresChallengeResponseAuth() const override + { + return getAuthenticationStrictness() == AuthenticationStrict; + } + /** @brief Key sealing legacy password backups, or empty when they are disabled. */ + QByteArray getLegacyBackupKey() const; + /** @brief Days a legacy password backup may go unused before it is purged. */ + int getLegacyBackupTtlDays() const; QString getServerName() const; QString getLoginMessage() const override { @@ -272,6 +288,7 @@ public: { return authenticationMethod; } + AuthenticationStrictness getAuthenticationStrictness() const; bool permitUnregisteredUsers() const override { return authenticationMethod != AuthenticationNone; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index d7912cfe6..04f6047fa 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,17 @@ inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); +// Oldest password_legacy_backup_used_at that is still inside the TTL. The value is +// compared against (and stored as) a UTC "yyyy-MM-dd HH:mm:ss" string, which sorts +// lexicographically, so no timezone-aware parsing is needed on either side. +static QString legacyBackupExpiryCutoff(int ttlDays) +{ + return QDateTime::currentDateTimeUtc().addDays(-ttlDays).toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); +} + +// Width of the password_legacy_backup column; a sealed backup must fit inside it. +static constexpr int MAX_LEGACY_BACKUP_LENGTH = 255; + Servatrice_DatabaseInterface::Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server) : instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server) { @@ -429,13 +441,104 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot qCWarning(DatabaseInterfaceLog) << "Login denied: user not active"; return UserIsInactive; } + + // Fail closed on an absent stored credential: an empty key would + // otherwise authenticate anyone who can compute HMAC("", nonce). + if (correctPasswordSha512.isEmpty()) { + qCWarning(DatabaseInterfaceLog) << "Login denied: empty stored credential"; + return NotLoggedIn; + } + + if (password.startsWith("$challenge$")) { + // Challenge-response login: verify HMAC(stored_key, nonce) without + // ever transmitting the stored credential or password hash. + const QStringList parts = password.split("$"); + if (parts.size() != 4) { + return NotLoggedIn; + } + const QByteArray nonce = QByteArray::fromBase64(parts.at(2).toUtf8()); + const QByteArray response = QByteArray::fromBase64(parts.at(3).toUtf8()); + if (nonce.isEmpty() || response.isEmpty() || !handler->isAuthNonceValid(nonce, user)) { + return NotLoggedIn; + } + + QByteArray key; + if (PasswordHasher::isLegacyFormat(correctPasswordSha512)) { + key = correctPasswordSha512.toUtf8(); + } else { + // Design note: the stored scrypt verifier IS the challenge-response key, so a + // database dump yields credentials that can answer a login challenge directly. + // We deliberately accept this trade: it removes plaintext passwords from the + // client config and from the wire, but does not protect against DB compromise. + // A password-equivalent proof scheme (e.g. SRP-6a/OPAQUE) would be the proper + // escalation and is out of scope here. + const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(correctPasswordSha512); + if (!verifier.isValid) { + return NotLoggedIn; + } + key = verifier.verifier; + } + + const QByteArray expected = PasswordHasher::computeResponse(key, nonce); + handler->clearAuthNonce(); + if (PasswordHasher::constantTimeEquals(expected, response)) { + qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right"; + return PasswordRight; + } + qCDebug(DatabaseInterfaceLog) << "Login denied: challenge-response password wrong"; + return NotLoggedIn; + } + + // A migrated (scrypt) row distinguishes how the credential arrived: + // a plaintext password (passwordNeedsHash) is verified directly against + // the scrypt verifier, so email changes (cmdAccountEdit) and old clients + // that still send `password` keep working without a legacy backup key. + // A pre-hashed legacy value is accepted against the encrypted legacy + // backup while that backup is fresh. Without one there is no downgrade + // path. The backup window is refreshed only when the backup is what + // actually matched, so a plaintext proof never keeps the + // fast-crackable hash alive. + if (!PasswordHasher::isLegacyFormat(correctPasswordSha512)) { + if (passwordNeedsHash) { + // Cost is bounded by costParamsAreSane inside parsePasswordVerifier. + if (PasswordHasher::verifyPassword(password, correctPasswordSha512)) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } + qCDebug(DatabaseInterfaceLog) << "Login accepted: password right"; + return PasswordRight; + } + qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong"; + return NotLoggedIn; + } + const QString backupCredential = getUsableLegacyBackup(user); + if (backupCredential.isEmpty()) { + qCDebug(DatabaseInterfaceLog) + << "Login denied: migrated account without a usable legacy backup"; + return NotLoggedIn; + } + if (PasswordHasher::constantTimeEquals(backupCredential.toUtf8(), password.toUtf8())) { + touchLegacyBackup(user); + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } + qCDebug(DatabaseInterfaceLog) << "Login accepted: password right"; + return PasswordRight; + } + qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong"; + return NotLoggedIn; + } + + QString storedCredential = correctPasswordSha512; QString hashedPassword; if (passwordNeedsHash) { - hashedPassword = PasswordHasher::computeHash(password, correctPasswordSha512.left(16)); + hashedPassword = PasswordHasher::computeHash(password, storedCredential.left(16)); } else { hashedPassword = password; } - if (correctPasswordSha512 == hashedPassword) { + if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) { if (forceChange) { qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; return PasswordChangeRequired; @@ -631,6 +734,185 @@ QString Servatrice_DatabaseInterface::getUserSalt(const QString &user) return {}; } +QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return {}; + } + + checkSql(); + + QSqlQuery *query = prepareQuery("SELECT password_sha512 FROM {prefix}_users WHERE name = :name"); + query->bindValue(":name", user); + if (!execSqlQuery(query)) { + return {}; + } + + if (!query->next()) { + return {}; + } + + return query->value(0).toString(); +} + +bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return false; + } + + checkSql(); + + // Read the credential that is about to be replaced: while it is still in the + // legacy format it becomes the encrypted downgrade backup, sealed before the + // row is overwritten with the scrypt verifier. + QSqlQuery *currentQuery = prepareQuery("SELECT name, password_sha512 FROM {prefix}_users WHERE name = :name"); + currentQuery->bindValue(":name", user); + if (!execSqlQuery(currentQuery) || !currentQuery->next()) { + return false; + } + // Use the stored row name (not the caller-supplied one) as the AAD: name is + // utf8mb4_unicode_ci, so `alice` logs in as `Alice` and the SELECT still finds + // the row while a client-typed name would fail the GCM tag check on open. + const QString storedName = currentQuery->value(0).toString(); + const QString currentCredential = currentQuery->value(1).toString(); + + QString sealedBackup; + QDateTime backupUsedAt; + if (PasswordHasher::isLegacyFormat(currentCredential)) { + const QByteArray key = server->getLegacyBackupKey(); + if (!key.isEmpty()) { + const QByteArray aad = storedName.simplified().toUtf8(); + sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad); + if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) { + qCWarning(DatabaseInterfaceLog) + << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; + sealedBackup.clear(); + } else { + backupUsedAt = QDateTime::currentDateTimeUtc(); + } + } + } + + // Only migrate accounts that still use the credential this backup was taken from; + // the guard makes a re-migration (or a raced second submission) a no-op. + QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512 = :verifier, " + "password_legacy_backup = :backup, password_legacy_backup_used_at = :used_at " + "where name = :user and password_sha512 = :expected and password_sha512 not like " + "'$%'"); + query->bindValue(":verifier", passwordVerifier); + query->bindValue(":backup", sealedBackup); + query->bindValue(":used_at", backupUsedAt.isValid() + ? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))) + : QVariant()); + query->bindValue(":user", user); + query->bindValue(":expected", currentCredential); + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to submit password verifier for user" << user << query->lastError(); + return false; + } + // The guard makes a re-migration a no-op; only report success when a row was actually updated. + return query->numRowsAffected() > 0; +} + +QString Servatrice_DatabaseInterface::getUsableLegacyBackup(const QString &user) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return {}; + } + + // Without a configured key the backup cannot be sealed or opened; do not touch + // (and never purge on this path) so a temporarily missing key cannot destroy it. + const QByteArray key = server->getLegacyBackupKey(); + if (key.isEmpty()) { + return {}; + } + + checkSql(); + + QSqlQuery *query = prepareQuery( + "SELECT name, password_legacy_backup, password_legacy_backup_used_at FROM {prefix}_users WHERE name = :name"); + query->bindValue(":name", user); + if (!execSqlQuery(query) || !query->next()) { + return {}; + } + + // Open with the stored row name as the AAD: sealing used the canonical DB name + // while the caller passes the client-typed name, and the two differ whenever + // the login case does not match the stored case (utf8mb4_unicode_ci). + const QString storedName = query->value(0).toString(); + const QString sealed = query->value(1).toString(); + const QString usedAt = query->value(2).toString(); + if (sealed.isEmpty()) { + return {}; + } + + // Age out: a backup unused for the whole TTL is purged on sight, so a database + // dump taken after the window closes never carries a crackable legacy hash. + if (usedAt.isEmpty() || usedAt < legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())) { + qCDebug(DatabaseInterfaceLog) << "Purging expired legacy backup for user" << user; + clearLegacyBackup(user); + return {}; + } + + const QByteArray legacyCredential = CryptoUtil::decryptSecret(sealed, key, storedName.simplified().toUtf8()); + if (legacyCredential.isEmpty()) { + qCWarning(DatabaseInterfaceLog) << "Legacy backup for user" << user << "failed to decrypt" + << "- check security/legacy_backup_key"; + return {}; + } + return QString::fromUtf8(legacyCredential); +} + +void Servatrice_DatabaseInterface::touchLegacyBackup(const QString &user) +{ + if (!checkSql()) { + return; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup_used_at = UTC_TIMESTAMP() " + "WHERE name = :name AND password_legacy_backup <> ''"); + query->bindValue(":name", user); + execSqlQuery(query); +} + +void Servatrice_DatabaseInterface::clearLegacyBackup(const QString &user) +{ + if (!checkSql()) { + return; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', " + "password_legacy_backup_used_at = NULL WHERE name = :name"); + query->bindValue(":name", user); + execSqlQuery(query); +} + +void Servatrice_DatabaseInterface::purgeExpiredLegacyBackups() +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return; + } + + if (!checkSql()) { + return; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', " + "password_legacy_backup_used_at = NULL " + "WHERE password_legacy_backup <> '' AND " + "(password_legacy_backup_used_at IS NULL OR password_legacy_backup_used_at < " + ":cutoff)"); + query->bindValue(":cutoff", legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())); + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Legacy backup sweep failed:" << query->lastError(); + return; + } + if (query->numRowsAffected() > 0) { + qCDebug(DatabaseInterfaceLog) << "Purged" << query->numRowsAffected() << "expired legacy password backups"; + } +} + int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name) { if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) { @@ -1349,7 +1631,10 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt()); } + // The backup authenticates the credential it was taken from; a new credential + // invalidates it (the old password is dead, and the new one has no legacy form). QSqlQuery *passwordQuery = prepareQuery("update {prefix}_users set password_sha512=:password, " + "password_legacy_backup='', password_legacy_backup_used_at=NULL, " "passwordLastChangedDate = NOW() where name = :name"); passwordQuery->bindValue(":password", passwordSha512); passwordQuery->bindValue(":name", user); @@ -1402,13 +1687,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, return false; } - const QString correctPasswordSha512 = passwordQuery->value(0).toString(); - QString oldPasswordSha512 = oldPassword; - if (oldPasswordNeedsHash) { - QString salt = correctPasswordSha512.left(16); - oldPasswordSha512 = PasswordHasher::computeHash(oldPassword, salt); - } - if (correctPasswordSha512 != oldPasswordSha512) { + const QString storedPassword = passwordQuery->value(0).toString(); + // oldPasswordNeedsHash means the client sent the old password in plaintext. Verify it + // against whatever is stored: legacy salt+hash rows or already-migrated scrypt rows + // (which must NOT be re-hashed with a salt torn out of the "$scrypt$..." string). + const bool oldPasswordMatches = oldPasswordNeedsHash ? PasswordHasher::verifyPassword(oldPassword, storedPassword) + : (oldPassword == storedPassword); + if (!oldPasswordMatches) { return false; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index eccd0d943..822accada 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -21,7 +21,7 @@ class QSqlQuery; -#define DATABASE_SCHEMA_VERSION 39 +#define DATABASE_SCHEMA_VERSION 40 class Servatrice; @@ -96,6 +96,18 @@ public: bool activeUserExists(const QString &user) override; bool userExists(const QString &user) override; QString getUserSalt(const QString &user) override; + QString getUserPasswordData(const QString &user) override; + bool submitPasswordVerifier(const QString &user, const QString &passwordVerifier) override; + /** @brief Decrypt the account's legacy password backup when it exists, is decryptable + * with the configured key and has not aged out; purges it when it has. Returns the + * legacy credential in the stored "salt + hash" format, or an empty string. */ + QString getUsableLegacyBackup(const QString &user); + /** @brief Refresh the age window of the account's legacy password backup. */ + void touchLegacyBackup(const QString &user); + /** @brief Drop the account's legacy password backup. */ + void clearLegacyBackup(const QString &user); + /** @brief Drop every legacy password backup that has not been used within the TTL. */ + void purgeExpiredLegacyBackups(); int getUserIdInDB(const QString &name); QMap getBuddyList(const QString &name) override; QMap getIgnoreList(const QString &name) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index b4d12599d..b24126d93 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -161,6 +161,7 @@ #include #include #include +#include #include #include #include @@ -204,7 +205,15 @@ bool AbstractServerSocketInterface::initSession() identEvent.set_server_version(VERSION_STRING); identEvent.set_protocol_version(protocolVersion); if (servatrice->getAuthenticationMethod() == Servatrice::AuthenticationSql) { + // Challenge-response is advertised in every strictness mode: legacy accounts keep + // logging in with the legacy hash, but already-migrated scrypt rows are always + // served challenge-response (authentication_strictness only governs NEW credentials). + // server_options stays exactly SupportsPasswordHash on the wire: already-released + // clients parse an unknown proto2 enum value as NoOptions and would fall back to + // plaintext passwords. Challenge support is advertised in the separate + // supports_challenge_response field, which old clients simply ignore. identEvent.set_server_options(Event_ServerIdentification::SupportsPasswordHash); + identEvent.set_supports_challenge_response(true); } SessionEvent *identSe = prepareSessionEvent(identEvent); sendProtocolItem(*identSe); @@ -409,6 +418,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc); case SessionCommand::REPORT_DETAILS: return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc); + case SessionCommand::SUBMIT_PASSWORD_VERIFIER: + return cmdSubmitPasswordVerifier(cmd.GetExtension(Command_SubmitPasswordVerifier::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -3298,6 +3309,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C password = QString::fromStdString(cmd.hashed_password()); } + // Reject credential formats the configured authentication strictness does not accept. + if (!acceptsCredentialFormat(passwordNeedsHash, password)) { + return Response::RespClientUpdateRequired; + } + bool requireEmailActivation = settingsCache->value("registration/requireemailactivation", true).toBool(); bool regSucceeded = sqlInterface->registerUser(userName, realName, password, passwordNeedsHash, parsedEmailAddress, country, !requireEmailActivation); @@ -3342,6 +3358,30 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i return servatrice->recordRegistrationAttempt(ipAddress); } +bool AbstractServerSocketInterface::acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const +{ + // An empty credential must never reach the database: it would be accepted + // as a legacy format and stored as '' (fail-open on login, see the empty + // stored-credential guard in Servatrice_DatabaseInterface). + if (password.isEmpty()) { + return false; + } + // "scryptFormat" means the client sent a derived verifier rather than a + // password to hash ourselves. parsePasswordVerifier enforces the sane-cost + // clamp, so nothing starting with '$' reaches the database unparsed. + const bool scryptFormat = !passwordNeedsHash && PasswordHasher::parsePasswordVerifier(password).isValid; + + // The strictness mode governs how existing legacy accounts are served, not which new-credential + // formats are tolerated: a legacy-mode server must still accept scrypt verifiers, because clients + // derive them whenever challenge-response is advertised (and it must be, so already-migrated + // scrypt rows keep logging in). strict is the only mode that rejects legacy formats. + if (servatrice->getAuthenticationStrictness() == Servatrice::AuthenticationStrict) { + return scryptFormat; + } + // legacy and mixed accept a valid scrypt verifier or a genuine legacy salt+hash. + return scryptFormat || PasswordHasher::isLegacyFormat(password); +} + Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd, ResponseContainer & /*rc*/) { @@ -3656,6 +3696,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C newPassword = QString::fromStdString(cmd.hashed_new_password()); } + // Reject new credential formats the configured authentication strictness does not accept. + if (!acceptsCredentialFormat(newPasswordNeedsHash, newPassword)) { + return Response::RespClientUpdateRequired; + } + QString userName = QString::fromStdString(userInfo->name()); if (!databaseInterface->changeUserPassword(userName, oldPassword, true, newPassword, newPasswordNeedsHash)) { return Response::RespWrongPassword; @@ -3802,6 +3847,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con password = QString::fromStdString(cmd.hashed_new_password()); } + // Reject new credential formats the configured authentication strictness does not accept. + if (!acceptsCredentialFormat(passwordNeedsHash, password)) { + return Response::RespClientUpdateRequired; + } + if (sqlInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) { if (servatrice->getEnableForgotPasswordAudit()) { sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), @@ -3869,9 +3919,11 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd, ResponseContainer &rc) { - const QString userName = nameFromStdString(cmd.user_name()); - QString passwordSalt = sqlInterface->getUserSalt(userName); - if (passwordSalt.isEmpty()) { + // Simplified exactly like cmdLogin, so the nonce bound to userName here validates + // against the same normalized name during the login's challenge-response check. + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + const QString storedPasswordData = sqlInterface->getUserPasswordData(userName); + if (storedPasswordData.isEmpty()) { if (server->getRegOnlyServerEnabled()) { return Response::RespRegistrationRequired; } else { @@ -3879,8 +3931,46 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con return Response::RespOk; } } + auto *re = new Response_PasswordSalt; - re->set_password_salt(passwordSalt.toStdString()); + if (PasswordHasher::isLegacyFormat(storedPasswordData)) { + re->set_password_salt(storedPasswordData.left(16).toStdString()); + re->set_needs_migration(true); + // Legacy rows get a challenge-response nonce only outside legacy mode (there the client + // logs in with the legacy hash and the account is migrated). In legacy mode the row is + // served the legacy salt, since legacy mode only governs what NEW credentials are accepted. + if (servatrice->getAuthenticationStrictness() != Servatrice::AuthenticationLegacy) { + const QByteArray nonce = CryptoUtil::randomBytes(32); + setAuthNonce(nonce, userName); + re->set_nonce(nonce.constData(), nonce.size()); + } + } else { + const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedPasswordData); + if (!verifier.isValid) { + delete re; + return Response::RespContextError; + } + const QString scryptSalt = QString(verifier.salt.toBase64()); + re->set_password_salt(scryptSalt.toStdString()); + re->set_scrypt_salt(scryptSalt.toStdString()); + re->set_n(verifier.n); + re->set_r(verifier.r); + re->set_p(verifier.p); + re->set_needs_migration(false); + // Pre-challenge clients derive the legacy hash from password_salt. While a fresh + // encrypted legacy backup exists, serve its salt there so they can still log in; + // challenge-response clients must use scrypt_salt, which always carries the + // verifier salt. + const QString legacyBackup = sqlInterface->getUsableLegacyBackup(userName); + if (!legacyBackup.isEmpty()) { + re->set_password_salt(legacyBackup.left(16).toStdString()); + } + // scrypt rows are served challenge-response in every mode so migrated accounts never lock out. + const QByteArray nonce = CryptoUtil::randomBytes(32); + setAuthNonce(nonce, userName); + re->set_nonce(nonce.constData(), nonce.size()); + } + rc.setResponseExtension(re); return Response::RespOk; } @@ -3978,6 +4068,39 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Re return Response::RespOk; } +Response::ResponseCode +AbstractServerSocketInterface::cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespLoginNeeded; + } + + // Limit to the size of the database column (password_sha512 varchar(255)). + constexpr int MAX_PASSWORD_VERIFIER_LENGTH = 255; + const QString passwordVerifier = QString::fromStdString(cmd.password_verifier()); + if (passwordVerifier.isEmpty() || passwordVerifier.length() > MAX_PASSWORD_VERIFIER_LENGTH || + PasswordHasher::isLegacyFormat(passwordVerifier)) { + return Response::RespContextError; + } + + // Reject unparseable or hostile cost parameters before they reach the database. + const PasswordVerifier parsedVerifier = PasswordHasher::parsePasswordVerifier(passwordVerifier); + if (!parsedVerifier.isValid) { + qCWarning(AbstractServerSocketInterfaceLog) + << "Rejecting password verifier submission with invalid or insane cost parameters"; + return Response::RespContextError; + } + + if (!sqlInterface->submitPasswordVerifier(QString::fromStdString(userInfo->name()), passwordVerifier)) { + return Response::RespContextError; + } + + qCDebug(AbstractServerSocketInterfaceLog) + << "Password verifier migrated for user" << QString::fromStdString(userInfo->name()); + return Response::RespOk; +} + // ADMIN FUNCTIONS. // Permission is checked by the calling function. diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 586bff831..dd9d3f2bb 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -103,6 +103,7 @@ class Command_ReportUserInfo; class Command_RequestPasswordSalt; class Command_ResetUserPassword; class Command_SetCardArtParams; +class Command_SubmitPasswordVerifier; class Command_UpdateAdminNotes; class Command_ViewLogHistory; class Command_WarnUser; @@ -124,6 +125,7 @@ protected: void logDebugMessage(const QString &message) override; bool tooManyRegistrationAttempts(const QString &ipAddress); void processCommandContainer(const CommandContainer &cont) override; + bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const; virtual void writeToSocket(QByteArray &data) = 0; virtual void flushSocket() = 0; @@ -205,6 +207,7 @@ private: Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc); + Response::ResponseCode cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, ResponseContainer &rc); Response::ResponseCode processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 2a504b81c..99f2e1400 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,7 +1,9 @@ #include "gtest/gtest.h" #include #include +#include #include +#include #include #include @@ -39,6 +41,204 @@ TEST(PasswordHashTest, TokenHasExpectedLength) const QString token = PasswordHasher::generateActivationToken(); ASSERT_EQ(token.size(), 16); } + +TEST(PasswordHashTest, DeriveKeyMatchesKnownVector) +{ + // RFC 7914 scrypt test vector, P="password", S="NaCl", N=1024, r=8, p=16 + const QByteArray expected = QByteArray::fromHex("fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b" + "3731622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cb" + "dfa2cc0640"); + const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16); + ASSERT_EQ(derived.toHex(), expected.toHex()); +} + +TEST(PasswordHashTest, PasswordVerifierRoundTrip) +{ + const QString stored = PasswordHasher::generatePasswordVerifier("hunter2"); + ASSERT_FALSE(stored.isEmpty()); + ASSERT_TRUE(stored.startsWith("$scrypt$")); + + const PasswordVerifier parsed = PasswordHasher::parsePasswordVerifier(stored); + ASSERT_TRUE(parsed.isValid); + ASSERT_EQ(parsed.format, PasswordFormat::Scrypt); + ASSERT_EQ(parsed.n, SCRYPT_N); + ASSERT_EQ(parsed.r, SCRYPT_R); + ASSERT_EQ(parsed.p, SCRYPT_P); + ASSERT_EQ(parsed.salt.size(), SCRYPT_SALT_LENGTH); + ASSERT_EQ(parsed.verifier.size(), SCRYPT_VERIFIER_LENGTH); +} + +TEST(PasswordHashTest, PasswordVerifierInvalidInput) +{ + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("garbage").isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$not-an-int$8$1$AAAA$BBBB").isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$1024$8$1$AAAA$too-short").isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$pbkdf2-sha512$1000$AAAA$BBBB").isValid); +} + +TEST(PasswordHashTest, LegacyFormatDetection) +{ + ASSERT_TRUE(PasswordHasher::isLegacyFormat("salt+hash")); + ASSERT_FALSE(PasswordHasher::isLegacyFormat(PasswordHasher::generatePasswordVerifier("password"))); +} + +TEST(PasswordHashTest, DeriveKeyDependsOnCostParameters) +{ + const QByteArray keyA = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16); + const QByteArray keyB = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 2048, 8, 16); + const QByteArray keyC = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 1); + ASSERT_NE(keyA, keyB); + ASSERT_NE(keyA, keyC); +} + +TEST(PasswordHashTest, ComputeResponseIsDeterministic) +{ + const QByteArray nonce = QByteArray("a nonce value"); + const QByteArray key = QByteArray("the verifier bytes"); + const QByteArray r1 = PasswordHasher::computeResponse(key, nonce); + const QByteArray r2 = PasswordHasher::computeResponse(key, nonce); + const QByteArray r3 = PasswordHasher::computeResponse(QByteArray("a different key"), nonce); + ASSERT_EQ(r1, r2); + ASSERT_NE(r1, r3); +} + +TEST(PasswordHashTest, ConstantTimeEquals) +{ + ASSERT_TRUE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("same"))); + ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("diff"))); + ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("short"), QByteArray("longer"))); +} + +TEST(PasswordHashTest, CostParamsAreSane) +{ + // Accept the recommended interactive parameters and the RFC 7914 test vector's. + ASSERT_TRUE(PasswordHasher::costParamsAreSane(SCRYPT_N, SCRYPT_R, SCRYPT_P)); + ASSERT_TRUE(PasswordHasher::costParamsAreSane(1024, 8, 16)); + + // n must be in [1024, 2**20] and a power of two. + ASSERT_FALSE(PasswordHasher::costParamsAreSane(512, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1 << 21, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1025, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(0, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(-1024, 8, 1)); + + // r in [1, 32], p in [1, 16]. + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 0, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 33, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 0)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 17)); +} + +TEST(PasswordHashTest, ParsePasswordVerifierRejectsHostileCostParams) +{ + // 16 bytes of salt and 64 bytes of verifier, base64 encoded. + const QString saltB64 = QLatin1String("c2FsdHNhbHRzYWx0c2FsdA=="); + const QString verifierB64 = QString(QByteArray(SCRYPT_VERIFIER_LENGTH, '\x42').toBase64()); + + ASSERT_TRUE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid); + + // n not a power of two, below 1024, or above 2**20. + ASSERT_FALSE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$1025$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid); + ASSERT_FALSE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$512$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid); + ASSERT_FALSE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$1073741824$8$1$%1$%2").arg(saltB64).arg(verifierB64)) + .isValid); + + // r and p out of range. + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$33$1$%1$%2").arg(saltB64).arg(verifierB64)) + .isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$17$%1$%2").arg(saltB64).arg(verifierB64)) + .isValid); +} + +TEST(PasswordHashTest, VerifyPasswordLegacyRow) +{ + const QString salt = PasswordHasher::generateRandomSalt(); + const QString legacyStored = PasswordHasher::computeHash("correct horse", salt); + ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", legacyStored)); + ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", legacyStored)); +} + +TEST(PasswordHashTest, VerifyPasswordScryptRow) +{ + const QString scryptStored = PasswordHasher::generatePasswordVerifier("correct horse"); + // Regression for the changeUserPassword bug that re-hashed the old password with + // a 16-char salt torn out of the "$scrypt$..." string, which could never match. + ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", scryptStored)); + ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", scryptStored)); + ASSERT_FALSE(PasswordHasher::verifyPassword("correct horse", "garbage")); +} + +TEST(CryptoUtilTest, SecretRoundTrip) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QByteArray plaintext = PasswordHasher::computeHash("correct horse", "saltsaltsaltsalt").toUtf8(); + const QByteArray aad = "alice"; + + const QString sealed = CryptoUtil::encryptSecret(plaintext, key, aad); + ASSERT_FALSE(sealed.isEmpty()); + ASSERT_TRUE(sealed.startsWith("enc1:")); + ASSERT_EQ(CryptoUtil::decryptSecret(sealed, key, aad), plaintext); +} + +TEST(CryptoUtilTest, SealIsNonDeterministic) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QByteArray plaintext = "same secret"; + // Fresh random nonce per seal: identical inputs must not produce identical blobs. + ASSERT_NE(CryptoUtil::encryptSecret(plaintext, key), CryptoUtil::encryptSecret(plaintext, key)); +} + +TEST(CryptoUtilTest, RejectsWrongKey) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QByteArray otherKey = CryptoUtil::randomBytes(32); + const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice"); + ASSERT_FALSE(sealed.isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, otherKey, "alice").isEmpty()); +} + +TEST(CryptoUtilTest, RejectsWrongAad) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice"); + ASSERT_FALSE(sealed.isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key, "bob").isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key).isEmpty()); +} + +TEST(CryptoUtilTest, RejectsTampering) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QString sealed = CryptoUtil::encryptSecret("secret", key); + ASSERT_FALSE(sealed.isEmpty()); + + // Flip a character inside the ciphertext segment (keep it valid base64). + QStringList parts = sealed.split(':'); + ASSERT_EQ(parts.size(), 3); + QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8()); + ciphertext[0] = static_cast(ciphertext.at(0) ^ 0x01); + parts[2] = QString(ciphertext.toBase64()); + ASSERT_TRUE(CryptoUtil::decryptSecret(parts.join(':'), key).isEmpty()); +} + +TEST(CryptoUtilTest, RejectsMalformedInput) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + ASSERT_TRUE(CryptoUtil::decryptSecret("garbage", key).isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:notbase64!!:also-not!", key).isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAA", key).isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret("", key).isEmpty()); + // A short or oversized key must never reach the cipher. + ASSERT_TRUE( + CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", CryptoUtil::randomBytes(16)) + .isEmpty()); + ASSERT_TRUE(CryptoUtil::encryptSecret("secret", CryptoUtil::randomBytes(16)).isEmpty()); +} + } // namespace int main(int argc, char **argv) From 09846d26f3d9e45f49a0a7467a5356e355b119d5 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:07:49 +0200 Subject: [PATCH 046/247] Translate oracle_en@source.ts in ca [Manual Sync] (#7548) 85% of minimum 5% translated source file: 'oracle_en@source.ts' on 'ca'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- oracle/translations/oracle_ca.ts | 626 +++++++++++++++++++++++++++++++ 1 file changed, 626 insertions(+) create mode 100644 oracle/translations/oracle_ca.ts diff --git a/oracle/translations/oracle_ca.ts b/oracle/translations/oracle_ca.ts new file mode 100644 index 000000000..f2e534409 --- /dev/null +++ b/oracle/translations/oracle_ca.ts @@ -0,0 +1,626 @@ + + + IntroPage + + + Introduction + Introducció + + + + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. + Aquest assistent importarà les llistes de col·leccions, cartes, i fitxes que seran utilitzades per Cockatrice. + + + + Interface language: + Llenguatge de la interfície: + + + + Version: + Versió: + + + + LoadSetsPage + + + Source selection + Selecció de font + + + + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. + + + + + Download URL: + Descarregar URL: + + + + Local file: + Fitxer local: + + + + Restore default URL + Restablir URL per defecte + + + + Choose file... + Tria un fitxer... + + + + Load sets file + + + + + Sets file (%1) + Sets JSON file (%1) + + + + + + + + + Error + Error + + + + The provided URL is not valid. + L'URL proporcionat no és vàlid. + + + + Please choose a file. + Si us plau trieu un fitxer. + + + + Cannot open file '%1'. + No es pot obrir el fitxer '%1'. + + + + Network error: %1. + Error de xarxa: %1. + + + + Parsing file + Analitzant el fitxer + + + + Xz extraction failed. + L'extracció Xz ha fallat. + + + + Sorry, this version of Oracle does not support xz compressed files. + Ho sento, aquesta versió d'Oracle no admet fitxers comprimits amb xz. + + + + Failed to open Zip archive: %1. + No s'ha pogut obrir l'arxiu Zip: %1. + + + + Zip extraction failed: the Zip archive doesn't contain exactly one file. + L'extracció del fitxer Zip ha fallat: l'arxiu Zip no conté exactament un fitxer. + + + + Zip extraction failed: %1. + L'extracció del fitxer Zip ha fallat: %1. + + + + Sorry, this version of Oracle does not support zipped files. + Ho sento, aquesta versió d'Oracle no admet fitxers comprimits. + + + + Failed to interpret downloaded data. + No s'han pogut interpretar les dades descarregades. + + + + Downloading (0 MB) + Descarregant (0 MB) + + + + Downloading (%1 MB) + Descarregant (%1 MB) + + + + Parsing file (%1%) + Analitzant el fitxer (%1%) + + + + Extracting file... + Extraient el fitxer... + + + + Do you want to download the uncompressed file instead? + Vols descarregar el fitxer sense comprimir en comptes d'això? + + + + + The file was retrieved successfully, but it does not contain any sets data. + + + + + Parsing file (100%) + Analitzant el fitxer (100%) + + + + LoadSpoilersPage + + + Save spoiler database + Desa la base de dades d'espòilers + + + + XML; spoiler database (*.xml) + XML; base de dades d'espòilers (*.xml) + + + + spoiler + espòiler + + + + Spoilers import + + + + + Please specify a compatible source for spoiler data. + + + + + Download URL: + Descarregar URL: + + + + Local file: + Fitxer local: + + + + Restore default URL + Restablir URL per defecte + + + + Choose file... + Tria un fitxer... + + + + The spoiler database will be saved at the following location: + + + + + Save to a custom path (not recommended) + Desa en una ruta personalitzada (no recomanat) + + + + LoadTokensPage + + + Save token database + Desa la base de dades de fitxes + + + + XML; token database (*.xml) + XML; base de dades de fitxes (*.xml) + + + + tokens + fitxes + + + + Tokens import + Importa fitxes + + + + Please specify a compatible source for token data. + Si us plau, especifiqueu una font compatible per a les dades de les fitxes. + + + + Download URL: + Descarregar URL: + + + + Local file: + Fitxer local: + + + + Restore default URL + Restablir URL per defecte + + + + Choose file... + Tria un fitxer... + + + + The token database will be saved at the following location: + La base de dades de fitxes es desarà a la ubicació següent: + + + + Save to a custom path (not recommended) + Desa en una ruta personalitzada (no recomanat) + + + + OracleImporter + + + Dummy set containing tokens + + + + + OracleWizard + + + Oracle Importer + Importador d'Oracle + + + + OutroPage + + + Finished + Finalitzat + + + + The wizard has finished. + + + + + You can now start using Cockatrice with the newly updated cards. + Ara ja pots començar a utilitzar Cockatrice amb les cartes recentment actualitzades. + + + + If the card databases don't reload automatically, restart the Cockatrice client. + Si les bases de dades de cartes no es recarreguen automàticament, reinicia el client de Cockatrice. + + + + SaveSetsPage + + + + Error + Error + + + + + No set has been imported. + No s'ha importat cap col·lecció. + + + + Sets imported + Col·leccions importades + + + + A Cockatrice card database file of %1 MB has been downloaded. + S'ha descarregat un fitxer de base de dades de cartes de Cockatrice de %1 MB + + + + The following sets have been found: + S'han trobat les col·leccions següents: + + + + Press "Save" to store the imported cards in the Cockatrice database. + Prem "Desa" per a desar les cartes importades a la base de dades de Cockatrice. + + + + The card database will be saved at the following location: + La base de dades de cartes es desarà a la ubicació següent: + + + + Save to a custom path (not recommended) + Desa en una ruta personalitzada (no recomanat) + + + + &Save + &Desa + + + + + Import finished: %1 cards. + Importació finalitzada: %1 cartes. + + + + + %1: %2 cards imported + %1: %2 cartes importades + + + + Save card database + Desa la base de dades de cartes + + + + XML; card database (*.xml) + XML; base de dades de cartes (*.xml) + + + + The file could not be saved to %1 + No s'ha pogut desar el fitxer a %1 + + + + SimpleDownloadFilePage + + + Load %1 file + + + + + %1 file (%1) + + + + + + + + + Error + Error + + + + The provided URL is not valid: + L'URL proporcionat no és vàlid. + + + + Downloading (0 MB) + Downloading (0MB) + Descarregant (0 MB) + + + + Please choose a file. + Si us plau trieu un fitxer. + + + + Cannot open file '%1'. + No es pot obrir el fitxer '%1'. + + + + Downloading (%1MB) + Descarregant (%1 MB) + + + + Network error: %1. + Error de xarxa: %1. + + + + The file could not be saved to %1 + No s'ha pogut desar el fitxer a %1 + + + + UnZip + + + ZIP operation completed successfully. + L'operació ZIP s'ha completat correctament. + + + + Failed to initialize or load zlib library. + No s'ha pogut inicialitzar o carregar la llibreria zlib. + + + + zlib library error. + Error de la llibreria zlib. + + + + Unable to create or open file. + No es pot crear ni obrir el fitxer + + + + Partially corrupted archive. Some files might be extracted. + + + + + Corrupted archive. + + + + + Wrong password. + Contrasenya incorrecta. + + + + No archive has been created yet. + Encara no s'ha creat cap arxiu. + + + + File or directory does not exist. + El fitxer o el directori no existeix. + + + + File read error. + Error de lectura del fitxer. + + + + File write error. + Error d'escriptura del fitxer + + + + File seek error. + Error de cerca del fitxer. + + + + Unable to create a directory. + No es pot crear un directori. + + + + Invalid device. + Dispositiu no vàlid. + + + + Invalid or incompatible zip archive. + Arxiu zip no vàlid o incompatible. + + + + Inconsistent headers. Archive might be corrupted. + + + + + Unknown error. + Error desconegut. + + + + Zip + + + ZIP operation completed successfully. + L'operació ZIP s'ha completat correctament. + + + + Failed to initialize or load zlib library. + No s'ha pogut inicialitzar o carregar la llibreria zlib. + + + + zlib library error. + Error de la llibreria zlib. + + + + Unable to create or open file. + No es pot crear ni obrir el fitxer + + + + No archive has been created yet. + Encara no s'ha creat cap arxiu. + + + + File or directory does not exist. + El fitxer o el directori no existeix. + + + + File read error. + Error de lectura del fitxer. + + + + File write error. + Error d'escriptura del fitxer + + + + File seek error. + Error de cerca del fitxer. + + + + Unknown error. + Error desconegut. + + + + i18n + + + English + Català (Catalan) + + + + main + + + Only run in spoiler mode + + + + + Run in no-confirm background mode + + + + \ No newline at end of file From 8e391f6fa0d537ec1517746123a367b72cd7097d Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:25:50 +0200 Subject: [PATCH 047/247] Translate oracle_en@source.ts in nb [Manual Sync] (#7558) 50% of minimum 5% translated source file: 'oracle_en@source.ts' on 'nb'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- oracle/translations/oracle_nb.ts | 200 +++++++++++++++++-------------- 1 file changed, 109 insertions(+), 91 deletions(-) diff --git a/oracle/translations/oracle_nb.ts b/oracle/translations/oracle_nb.ts index 698076fd5..2d13e2687 100644 --- a/oracle/translations/oracle_nb.ts +++ b/oracle/translations/oracle_nb.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Introduksjon - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. - + Interface language: - + Version: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Kilde valg - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: Nedlastings URL - + Local file: Lokal fil - + Restore default URL Gjenopprett standard URL - + Choose file... Velg fil... - + Load sets file Last set fil... - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Feil - + The provided URL is not valid. URL du anga er ikke gyldig. - - Downloading (0MB) - Laster ned (0MB) - - - + Please choose a file. Vennligst velg en fil - + Cannot open file '%1'. Kunne ikke åpme '%1' - - Downloading (%1MB) - Laster ned (%1MB) - - - + Network error: %1. Nettverks feil: %1 - + Parsing file Tolker fil. - + Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. Kunne ikke åpne Zip Arkiv: %1 - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Zip ekstraksjon feilet: Zip arkivet inneholder ikke nøyaktig en fil - + Zip extraction failed: %1. Zip ekstraksjon feilet: %1 - + Sorry, this version of Oracle does not support zipped files. Beklager, denne versjonen av Oracle støtter ikke Zip filer. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. Filen ble hentet riktig, men den inneholder ikke noe set data + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database - + XML; spoiler database (*.xml) - + spoiler - + Spoilers import - + Please specify a compatible source for spoiler data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + Tokens import - + Please specify a compatible source for token data. - + Download URL: Nedlastings URL - + Local file: - + Restore default URL Gjenopprett standard URL - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Dummy sett som inneholder tokens @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle importerer @@ -292,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Feil - + + No set has been imported. Ingen set har blitt importert. - + Sets imported Set importert - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. Importering fullført: %1 kort. - + + %1: %2 cards imported %1: %2 kort importert. - + Save card database Lagre kort database. - + XML; card database (*.xml) XML; kort database (*.xml) - + The file could not be saved to %1 Filen kunne ikke lagres til %1 @@ -414,7 +431,8 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) @@ -587,7 +605,7 @@ i18n - + English Norsk Bokmål (Norwegian Bokmål) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode From a331da10e882843e7d6373dd6c1c332d81ef717b Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:40:45 +0200 Subject: [PATCH 048/247] Updates for project Cockatrice and language fr (#7553) * Translate oracle_en@source.ts in fr [Manual Sync] 93% of minimum 5% translated source file: 'oracle_en@source.ts' on 'fr'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in fr [Manual Sync] 68% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'fr'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_fr.ts | 7454 ++++++++++++++++------ oracle/translations/oracle_fr.ts | 204 +- 2 files changed, 5630 insertions(+), 2028 deletions(-) diff --git a/cockatrice/translations/cockatrice_fr.ts b/cockatrice/translations/cockatrice_fr.ts index c85b6fbdd..343cf16cc 100644 --- a/cockatrice/translations/cockatrice_fr.ts +++ b/cockatrice/translations/cockatrice_fr.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Nombre de marqueur&s… @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Nombre de marqueurs - + New value for counter '%1': Nouvelle valeur des marqueurs « %1 » : @@ -36,66 +36,115 @@ AbstractTabDeckEditor - + Open in new tab Ouvrir dans un nouvel onglet - + Are you sure? Êtes-vous sûr ? - + The decklist has been modified. Do you want to save the changes? La liste de deck a été modifiée. Voulez-vous enregistrer les modifications ? - - - - - - + + + + + + Error Erreur - + Could not open deck at %1 N'a pas pu ouvrir le deck à %1 - + Could not save remote deck N'a pas pu sauvegarder le deck distant - - + + The deck could not be saved. Please check that the directory is writable and try again. Le deck n'a pas pu être enregistré. Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. - + Save deck Sauvegarder le deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. Le deck n'a pas pu être enregistré. - + There are no cards in your deck to be exported Il n'y a pas de cartes dans le deck à exporter + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. AdminNotesDialog - + Update Notes Notes de mises à jour - + Admin Notes for %1 Notes d'administrateur pour %1 @@ -138,218 +187,304 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. AppearanceSettingsPage - + seconds secondes - + Error Erreur - + Could not create themes directory at '%1'. N'a pas pu créer le dossier Thèmes à '%1' - + Theme settings Paramètres du thème - + Current theme: Thème actuel : - + Open themes folder Ouvrir le dossier des thèmes - + Home tab background source: Source d'arrière-plan de l'onglet Accueil: - + Home tab background shuffle frequency: Fréquence de mélange de l'arrière-plan de l'onglet Accueil: - + Disabled Désactivé - + Menu settings Options du Menu - + Show keyboard shortcuts in right-click menus Montrer les raccourcis clavier dans les menus clic-droit - + Show game filter toolbar above list in room tab Afficher les barres d'outil et de recherche au-dessus de la liste des parties dans l'onglet salon. - + Card rendering Rendu des cartes - + Display card names on cards having a picture Afficher les noms des cartes ayant une image - + Auto-Rotate cards with sideways layout Rotation automatique des cartes avec disposition horizontale - + Override all card art with personal set preference (Pre-ProviderID change behavior) Remplacer toutes les illustrations de cartes par les préférences personnelles (comportement avant modification du ProviderID) - + Light - + Dark - + + System Système - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right Afficher le nom de la carte d'arrière-plan en bas à droite - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings Images de la carte - + Bump sets that the deck contains cards from to the top in the printing selector Mettre en avant que ce deck contient des cartes depuis le sommet, dans le sélecteur d'impression - + Scale cards on mouse over Agrandir les cartes lors du survol du curseur - + Use rounded card corners Utiliser des bords de cartes arrondis - + Card layout Disposition de la carte - + Minimum overlap percentage of cards on the stack and in vertical hand Pourcentage minimum de chevauchement des cartes dans la pile et dans la main verticale - + Maximum initial height for card view window: Hauteur initiale maximale pour la fenêtre d'affichage des cartes : - - + + rows lignes - + Maximum expanded height for card view window: Hauteur étendue maximale pour la fenêtre d'affichage des cartes : - + Card counters Marqueurs sur la carte - + Counter %1 Marqueur %1 - + Hand layout Disposition de la main - + Display hand horizontally (wastes space) Afficher la main horizontalement (perte d'espace) - + Enable left justification Activer la justification à gauche - + Table grid layout Disposition en forme de grille - + Invert vertical coordinate Inverser la disposition du champ de bataille - + Minimum player count for multi-column layout: Nombre minimum de joueurs pour la disposition multi-colonnes : - + Maximum font size for information displayed on cards: Taille maximale de la police pour les informations affichées sur les cartes  : @@ -357,12 +492,12 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. ArchidektApiResponseDeckDisplayWidget - + Back to results Retour aux résultats - + Open Deck in Deck Editor Ouvrir le deck dans l'éditeur de deck @@ -388,112 +523,112 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. BanDialog - + ban &user name bannir &nom d'utilisateur - + ban &IP address bannir &adresse IP - + ban client I&D bannir &ID du client - + Ban type Type du bannissement - + &permanent ban &bannissement permanent - + &temporary ban bannissement &temporaire - + &Days: &Jours : - + &Hours: &Heures : - + &Minutes: &Minutes : - + Duration of the ban Durée du bannissement - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Veuillez expliquer la raison du bannissement. Cette information sera consultable uniquement par les modérateurs et ne sera pas visible par la personne bannie. - + Please enter the reason for the ban that will be visible to the banned person. Veuillez expliquer la raison du bannissement. Cette information sera visible par la personne bannie. - + Redact all messages from this user in all rooms Censurer tous les messages de cet utilisateur dans toutes les salles - + &OK &OK - + &Cancel &Annuler - + Ban user from server Bannir le joueur du serveur - - - - + + + + Error Erreur - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Vous devez choisir un bannissement à partir du nom, de l'adresse IP, de l'ID du client, ou une combinaison des trois. - + You must have a value in the name ban when selecting the name ban checkbox. Vous devez remplir le champ du nom du banni en le choisissant dans la liste. - + You must have a value in the ip ban when selecting the ip ban checkbox. Vous devez saisir l'adresse IP du banni en le choisissant dans la liste. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Vous devez saisir l'ID du client banni en le choisissant dans la liste. @@ -501,22 +636,22 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Le serveur de mise à jour ne répond pas. - + Invalid reply received from the release update server. Réponse reçue du serveur de mise à jour de version invalide . - + No reply received from the file update server. Le serveur de mise à jour de fichier ne répond pas. @@ -524,7 +659,20 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected Aucune carte sélectionné @@ -532,22 +680,22 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardArtRulesModel - + Card Carte - + ProviderId - + Mode Mode - + Reason Raison @@ -555,65 +703,209 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardDatabaseModel - + Name Nom - + Sets Éditions - + Mana cost Coût de mana - + Card type Type de carte - + P/T F/E - + Color(s) Couleur(s) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck Ajouter au deck - + Add to Sideboard - + Select Printing Choisir l'image - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -723,22 +1015,22 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardInfoFrameWidget - + Image Image - + Description Description - + Both Les deux - + View transformation Voir la transformation @@ -746,22 +1038,22 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardInfoPictureWidget - + View related cards Voir les cartes associées - + Add card to deck Ajouter la carte au deck - + Mainboard Deck principal - + Sideboard Réserve @@ -769,27 +1061,27 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardInfoTextWidget - + Name: Nom: - + Set: Édition : - + Collector Number: - + Related cards: Cartes associées : - + Unknown card: Carte inconnue : @@ -797,129 +1089,129 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardMenu - + Re&veal to... Révéler à... - + &All players &Tous les joueurs - + View related cards Voir les cartes associées - + Token: Jeton : - + All tokens Tout les jetons - + &Select All &Sélectionner Tout - + S&elect Row Sél&ectionne une ligne - + S&elect Column Sél&ectionne une colonne - + &Play &Jouer - + &Hide &Cacher - + Play &Face Down Jouer &Face Cachée - + &Tap / Untap Turn sideways or back again &Engager / Dégager - + Skip &untapping Ne pas dégager - + T&urn Over Turn face up/face down Reto&urner - + &Peek at card face &Regarder furtivement la carte face cachée - + &Clone &Cloner - + Attac&h to card... Attac&her à une carte... - + Unattac&h Détac&her - + &Draw arrow... &Tracer une flèche... - + &Set annotation... &Annoter... - + Reduce life by power - + Ca&rd counters Ma&rqueurs sur la carte - + &Add counter (%1) &Ajouter un marqueur (%1) - + &Remove counter (%1) &Retirer un marqueur (%1) - + &Set counters (%1)... &Changer le nombre de marqueurs (%1)... @@ -940,7 +1232,7 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardSizeWidget - + Card Size Taille de la Carte @@ -1080,6 +1372,24 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa La zone personnalisée '%2' de %1 + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1109,280 +1419,382 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa Choisir la couleur + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator Banni par le modérateur - + Expected end time: %1 Heure de fin estimé: %1 - + This ban lasts indefinitely. Ce bannissement dure indéfiniment. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1390,113 +1802,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password Réinitialiser le mot de passe - + Your password has been reset successfully, you can now log in using the new credentials. Votre mot de passe à été réinitialisé avec succes, vous pouvez maintenant vous connecter avec le nouveau mot de passe. - + Failed to reset user account password, please contact the server operator to reset your password. Échec de la réinitialisation du mot de passe. Veuillez contacter l'administrateur du serveur pour réinitialiser votre mot de passe. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1546,7 +1958,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database Base de données de cartes @@ -1562,17 +1974,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Rechercher par nom de carte (ou expression de recherche) - + Add card to &maindeck Ajouter la carte au &deck - + Add card to &sideboard Ajouter carte à la ré&serve @@ -1580,97 +1992,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... Chargement de la base de données... - + Banner Card Carte bannière - + Main Type Type principal - + Mana Cost Coût de mana - + Colors Couleurs - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing Choisir l'impression - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Deck - + Deck &name: &Nom du deck : - + Banner Card/Tags Visibility Settings Paramètres de visibilité de la carte bannière et des étiquettes - + Show banner card selection menu Montrer le menu de sélection de carte bannière - + Show tags selection menu Montrer le menu de sélection d'étiquettes - + &Comments: &Commentaires : - + Group by: Grouper par : - + Format: Format : - + Hash: Empreinte : - + &Increment number Augmenter quant&ité - + &Decrement number &Diminuer quantité - + &Remove row &Retirer la ligne - + Swap card to/from sideboard Échanger la carte vers/depuis la réserve @@ -1678,17 +2151,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Filtres - + &Clear all filters &Effacer tous les filtres - + Delete selected Enlever la sélection @@ -1696,114 +2169,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor &Éditeur de deck - + &New deck &Nouveau deck - + &Load deck... &Charger un deck... - + Load recent deck... Charger un deck récent... - + Clear Effacer - + &Save deck &Sauvegarder le deck - + Save deck &as... S&auvegarder le deck sous... - + + Share deck... + + + + Load deck from cl&ipboard... Charger un deck depuis le presse-pap&ier... - + Edit deck in clipboard Éditer le deck dans le presse-papier - - + + Annotated Avec annotations - - + + Not Annotated Sans annotation - + Save deck to clipboard Copier le deck dans le presse-papier - + Annotated (No set info) Annoté (pas d'info sur l'extension) - + Not Annotated (No set info) Pas annoté (pas d'info sur l'extension) - + &Print deck... Im&primer le deck... - + Load deck from online service... Charger un deck depuis un service en ligne... - + &Send deck to online service Envoyer le deck au service en &ligne - + Create decklist (decklist.org) Créer une liste de deck (decklist.org) - + Create decklist (decklist.xyz) Créer une liste de deck (decklist.xyz) - + Analyze deck (deckstats.net) &Analyser le deck sur deckstats.net - + Analyze deck (tappedout.net) Analyser le deck (tappedout.net) - + &Close &Fermer @@ -1811,7 +2289,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector Sélection d'impression @@ -1819,110 +2297,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Mettre à jour les spoilers - + Success Réussite - + Download URLs have been reset. Les URLs de téléchargement ont été réinitialisées. - + Add URL Ajouter une URL - - + + URL: URL : - - + + Edit URL Modifier l'URL - + Add New URL Ajouter Nouveau Lien - + Remove URL Effacer Lien - + Updating... Mise à jour... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Choisir le chemin - + URL Download Priority URL de téléchargement prioritaire - + Spoilers Spoilers - + Download Spoilers Automatically Télécharger automatiquement les spoilers - + Spoiler Location: Emplacement des spoilers : - + Last Change Dernier changement - + Spoilers download automatically on launch Télécharger automatiquement les spoilers au lancement - + Press the button to manually update without relaunching Appuyez sur le bouton pour mettre à jour manuellement sans redémarrer. - + Do not close settings until manual update is complete Ne pas fermer la fenêtre des paramètres avant la mise à jour manuelle complète. - + Download card pictures on the fly Télécharger les images des cartes à la volée - + How to add a custom URL Comment ajouter une URL personnalisée - + Reset Download URLs Réinitialiser les URL de téléchargement @@ -1930,32 +2450,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo Défaire - + Redo Refaire - + Undo/Redo history Historique - + Click on an entry to revert to that point in the history. Clickez sur une ligne pour retourner à ce point dans l'historique. - + [redo] [refaire] - + [undo] [défaire] @@ -1963,27 +2483,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Compter - + Set Édition - + Number Nombre - + Provider ID Provider ID - + Card Carte @@ -2004,17 +2524,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Mode : correspondance exacte - + Mode: Includes Mode : inclure - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) Mode de filtre par identité de couleur (conjonctions AND/OR/NOT de filtres) @@ -2022,7 +2547,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Éditer les étiquettes... @@ -2030,62 +2555,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Gestionnaire d'étiquettes de decks - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: Gérer vos étiquettes de decks. Ajouter ou retirer les étiquettes comme nécessaire, ou en créer de nouveaux : - + Add a new tag (e.g., Aggro️) Ajouter une nouvelle étiquette (e.g., Elfes) - + Add Tag Ajouter Étiquette - + Filter tags... Filtrer les étiquettes... - + OK OK - + Edit default tags Éditer les étiquettes par défaut - + Cancel Annuler - + Invalid Input Entrée invalide - + Tag name cannot be empty! Le nom de l'étiquette ne peut pas être vide ! - + Duplicate Tag Étiquette dupliquée - + This tag already exists. Cette étiquette existe déjà. @@ -2093,94 +2618,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card Carte bannière - + Open in deck editor Ouvrir dans l'éditeur de deck - + + Share deck... + + + + Edit Tags Éditer les étiquettes - + Rename Deck Renommer le deck - + Save Deck to Clipboard Copier le deck dans le presse-papier - + Annotated Avec annotations - + Annotated (No set info) Avec annotations (pas d'info sur l'extension) - + Not Annotated Sans annotation - + Not Annotated (No set info) Sans annotation (pas d'info sur l'extension) - + Rename File Renommer le fichier - + Delete File Supprimer le fichier - + Set Banner Card Choisir la carte bannière - - + + New name: Nouveau nom : - - + + Rename deck + + + + + Rename file + + + + + Error Erreur - + Rename failed Renommage échoué - + Delete file Supprimer le fichier - + Are you sure you want to delete the selected file? Êtes-vous certain de vouloir supprimer le fichier sélectionné ? - + Delete failed Suppression échouée @@ -2188,57 +2728,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" Renommer le deck de "%2" à "%1" - + Updated comments (was %1 chars, now %2 chars) Mis à jour les commentaires (étaient %1 caractères, maintenant %2) - + Set banner card to %1 (%2) Définir %1 (%2) comme carte de couverture - + + Set playmat to %1 + + + + Tags changed Tags modifiés - + Set format to %1 Changer le format à %1 - + Added (%1): %2 (%3) %4 Ajouté (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Bougé %1 1 × "%2" (%3) - + Removed "%1" (all copies) Retiré "%1" (toutes les copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Ajoutée - + Removed Retirée @@ -2260,88 +2845,116 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Charger un deck... - + Load remote deck... Charger un deck distant... - + Load from clipboard... Charger depuis le presse-papier... - + Load from website... Charger depuis un site web... - + Unload deck Annuler le chargement du deck - + Ready to start Prêt à démarrer - + Force start Forcer le démarrage - + Sideboard unlocked Réserve déverrouillée - + Sideboard locked Réserve verrouillée - - + + Error Erreur - + The selected file could not be loaded. Le fichier sélectionné n'a pas pu être chargé. - + Deck is greater than maximum file size. Le deck est plus grand que la taille maximum de fichier. - + Are you sure you want to force start? This will kick all non-ready players from the game. Êtes-vous sûr de vouloir forcer le démarrage ? Cela va éjecter de la partie tous les joueurs non prêts à démarrer. - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion Conversion de format de deck - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? @@ -2350,7 +2963,7 @@ Les étiquettes ne peuvent être ajoutées qu'aux decks au format .cod. Voulez-vous convertir le deck au format .cod ? - + Remember and automatically apply choice in the future Mémoriser et appliquer automatiquement le choix à l'avenir @@ -2363,143 +2976,143 @@ Voulez-vous convertir le deck au format .cod ? Téléchargement - + Known Hosts Hôtes connus - + Delete the currently selected saved server Supprimer le serveur enregistré actuellement sélectionné - + Refresh the server list with known public servers Actualiser la liste de serveurs avec des serveurs publics connus - + New Host Nouvel hôte - + Name: Nom  : - + &Host: &Hôte : - + &Port: &Port : - + Player &name: &Nom du joueur : - + P&assword: Mot de p&asse : - + &Save password &Se souvenir du mot de passe - + A&uto connect Connexion a&utomatique - + Automatically connect to the most recent login when Cockatrice opens Se connecter automatiquement avec les identifiants les plus récents à l'ouverture de Cockatrice - + If you have any trouble connecting or registering then contact the server staff for help! Si vous rencontrez des difficultés pour vous connecter ou vous inscrire, contactez le personnel du serveur pour obtenir de l'aide ! - - + + Webpage Page web - + Reset Password Réinitialiser le mot de masse - + Forgot password? Mot de passe oublié ? - + &Connect &Connecter - + Server Serveur - + Login Identifiants - + Server Contact Contact du serveur - + Connect to Server Se connecter au serveur - + Server URL URL du serveur - + Communication Port Port de communication - + Unique Server Name Nom de serveur unique - + Connection Warning Avertissement de connexion - + You need to name your new connection profile. Vous devez attribuer un nom à votre nouveau profil de connexion. - + Connect Warning Avertissement de connexion - + The player name can't be empty. Le nom du joueur ne peut être vide. @@ -2507,122 +3120,122 @@ Voulez-vous convertir le deck au format .cod ? DlgCreateGame - + Re&member settings Conserver les para&mètres - + &Description: &Description : - + P&layers: &Joueurs : - + General Général - + Game type Type de partie - + &Password: Mot de &passe : - + Only &buddies can join Seuls les &amis peuvent rejoindre - + Only &registered users can join Seuls les joueurs en&registrés peuvent rejoindre - + Joining restrictions Conditions pour rejoindre - + &Spectators can watch Les &spectateurs peuvent observer - + Spectators &need a password to watch Les spectateurs ont besoin d'un &mot de passe pour observer - + Spectators can &chat Les spectateurs peuvent dis&cuter - + Spectators can see &hands Les spectateurs peuvent voir les &mains des joueurs - + Create game as spectator Créer la partie en tant que spectateur - + Spectators Spectateurs - + Starting life total: Total initial de point de vie - + Open decklists in lobby Ouvrir les listes de deck dans le lobby - + Create game as judge Créer une partie en tant que juge - + Game setup options Options de configuration de la partie - + &Clear Effa&cer - + Create game Créer une partie - + Game information Informations sur la partie - + Error Erreur - + Server error. Erreur serveur. @@ -2630,97 +3243,97 @@ Voulez-vous convertir le deck au format .cod ? DlgCreateToken - + &Name: &Nom : - + Token Jeton - + C&olor: C&ouleur : - + white blanc - + blue bleu - + black noir - + red rouge - + green vert - + multicolor multicolore - + colorless incolore - + &P/T: &F/E : - + &Annotation: &Annotation : - + &Destroy token when it leaves the table &Detruire le jeton lorsqu'il quitte le champ de bataille - + Create face-down (Only hides name) Créer face-cachée (cache seulement le nom) - + Token data Données du jeton - + Show &all tokens Afficher &tous les jetons - + Show tokens from this &deck Afficher les jetons présents dans ce &deck - + Choose token from list Choisir un jeton dans la liste - + Create token Créer un jeton @@ -2728,53 +3341,53 @@ Voulez-vous convertir le deck au format .cod ? DlgDefaultTagsEditor - + Edit Tags Éditer les étiquettes - + Add Ajouter - + Confirm Confirmer - + Cancel Annuler - + Enter a tag and press Enter Entrer une étiquette et valider avec Entrée - - + + ✖ ✖ - + Invalid Input Entrée invalide - + Tag name cannot be empty! Le nom de l'étiquette ne peut pas être vide ! - + Duplicate Tag Étiquette dupliquée - + This tag already exists. Cette étiquette existe déjà. @@ -2783,7 +3396,7 @@ Voulez-vous convertir le deck au format .cod ? DlgEditAvatar - + No image chosen. Aucune image choisie. @@ -2795,27 +3408,32 @@ To remove your current avatar, confirm without choosing a new image. Pour enlever votre avatar actuel, confirmez sans choisir une nouvelle image. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Parcourir... - + Change avatar Changer d'avatar - + Open Image Ouvrir l'image - + Image Files (*.png *.jpg *.bmp) Fichiers image (*.png *.jpg *.bmp) - + Invalid image chosen. L'image choisie est invalide. @@ -2841,38 +3459,38 @@ Pour enlever votre avatar actuel, confirmez sans choisir une nouvelle image. DlgEditPassword - + Old password: Ancien mot de passe : - + New password: Nouveau mot de passe : - + Confirm new password: Confirmer le nouveau mot de passe : - + Change password Changer le mot de passe - - + + Error Erreur - + Your password is too short. Mot de passe trop court - + The new passwords don't match. Les nouveaux mots de passe ne correspondent pas. @@ -2880,93 +3498,93 @@ Pour enlever votre avatar actuel, confirmez sans choisir une nouvelle image. DlgEditTokens - + &Name: &Nom : - + C&olor: C&ouleur : - + white blanc - + blue bleu - + black noir - + red rouge - + green vert - + multicolor multicolore - + colorless incolore - + &P/T: &F/E : - + &Annotation: &Annotation : - + Token data Données du jeton - - + + Add token Ajouter un jeton - + Remove token Retirer le jeton - + Edit custom tokens Éditer les jetons personnalisés - + Please enter the name of the token: Entrez le nom du jeton : - + Error Erreur - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Le nom choisi est en conflit avec une carte ou un jeton existant. @@ -3045,8 +3663,9 @@ Assurez-vous d'activer l'édition « Fausse édition contenant les je - Hide games that have started - Cacher les parties qui ont commencées + Hide started games + Hide games that have started + @@ -3081,8 +3700,9 @@ Assurez-vous d'activer l'édition « Fausse édition contenant les je - &Creator name: - Nom du &créateur : + &Host name: + &Creator name: + @@ -3148,52 +3768,52 @@ Assurez-vous d'activer l'édition « Fausse édition contenant les je DlgForgotPasswordChallenge - + Reset Password Challenge Warning Avertissement Réinitialisation du mot de passe Challenge - + A problem has occurred. Please try to request a new password again. Un probleme est survenu. Veuillez réessayer de demander un nouveau mot de passe. - + Enter the information of the server and the account you'd like to request a new password for. Veuillez entrer les informations du serveur et du compte pour lesquels vous voulez demander un nouveau mot de passe. - + &Host: &Hôte : - + &Port: &Port : - + Player &name: &Nom du joueur : - + Email: Adresse e-mail : - + Reset Password Challenge Réinitialisation du mot de passe Challenge - + Reset Password Challenge Error Erreur Réinitialisation du mot de passe Challenge - + The email address can't be empty. L'adresse e-mail ne peut être vide. @@ -3201,37 +3821,37 @@ Assurez-vous d'activer l'édition « Fausse édition contenant les je DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Entrez les informations du serveur pour lequel vous souhaitez demander un nouveau mot de passe - + &Host: &Hôte : - + &Port: &Port : - + Player &name: &Nom du joueur : - + Reset Password Request Demande de réinitialisation du mot de passe - + Reset Password Error Erreur lors de la réinitialisation du mot de passe - + The player name can't be empty. Le nom du joueur ne peut être vide. @@ -3239,94 +3859,127 @@ Assurez-vous d'activer l'édition « Fausse édition contenant les je DlgForgotPasswordReset - + Reset Password Warning Avertissement lors de la réinitialisation du mot de passe - + A problem has occurred. Please try to request a new password again. Un problème est survenu. Veuillez demander un nouveau mot de passe. - + Enter the received token and the new password in order to set your new password. Entrez le code reçu et le nouveau mot de passe afin d'initialiser le nouveau mot de passe - + &Host: &Hôte : - + &Port: &Port : - + Player &name: &Nom du joueur : - + Token: Jeton : - - + + New Password: Nouveau mot de passe : - + Reset Password Réinitialiser le mot de passe - + The player name can't be empty. Le nom du joueur ne peut être vide. - - - - + + + + Reset Password Error Erreur de réinitialisation du mot de passe - + The token can't be empty. Le jeton ne peut pas être vide. - + The new password can't be empty. Le nouveau mot de passe ne peut être vide. - + Error Erreur - + Your password is too short. Mot de passe trop court - + The passwords do not match. Les mots de passe ne correspondent pas. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck Charger un deck @@ -3416,165 +4069,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: Joueurs : - + General Général - + Starting life total: Total de vie initial : - + Game setup options Options de configuration de la partie - + Remember settings Conserver les paramètres - + Local game options Options de configuration de partie locale + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Nom de la carte (ou expression de recherche) : - + Number of hits: Nombre de cartes trouvées : - + Auto play hits Jouer les cartes trouvées automatiquement - + Put top cards on stack until... Placer la carte du dessus sur la pile jusqu'à... - + No cards matching the search expression exists in the card database. Proceed anyways? Aucune carte ne correspondant à l'expression recherchée n'existe dans la base de données de cartes. Continuer malgré tout ? - + Cockatrice Cockatrice - + Invalid filter Filtre invalide + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Entrez votre identifiant et l'identifiant du serveur auquel vous voulez vous connecter. Un e-mail va vous être envoyé pour vérifier votre compte. - + &Host: &Hôte : - + &Port: &Port : - + Player &name: &Nom du joueur : - + P&assword: Mot de p&asse : - + Password (again): Mot de passe (encore) : - + Email: Adresse mail : - + Email (again): Adresse mail (encore) : - + Country: Pays : - + Undefined Indéfini - + Real name: Nom réel : - + Register to server S'enregistrer sur le serveur - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Avertissement d'enregistrement - + Your password is too short. Mot de passe trop court - + Your passwords do not match, please try again. Vos mots de passe ne correspondent pas, veuillez réessayer. - + Your email addresses do not match, please try again. Vos adresses mail ne correspondent pas, veuillez réessayer. - + The player name can't be empty. Le nom du joueur ne peut pas être vide. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3640,46 +4694,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Erreur inconnue lors du chargement de la base de données de cartes. - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Votre base de données de cartes est invalide. - -Cockatrice peut ne pas fonctionner correctement avec une base de données invalide. - -Vous devrez peut-être redémarrer Oracle pour mettre à jour votre base de données de cartes. - -Voulez-vous changer l'emplacement de votre base de données ? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - La version de votre base de données de cartes est trop ancienne. - -Cela peut causer des problèmes lors du chargement des images ou des informations des cartes. - -Généralement, il suffit de redémarrer oracle pour mettre à jour votre base de données de cartes. - -Voulez-vous changer l'emplacement de votre base de données ? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3692,7 +4712,7 @@ Veuillez ouvrir un ticket sur https://github.com/Cockatrice/Cockatrice/issues av Voulez-vous changer l'emplacement des données ? - + File Error loading your card database. Would you like to change your database location setting? @@ -3701,7 +4721,7 @@ Would you like to change your database location setting? Voulez-vous changer l'emplacement de votre base de données ? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3710,7 +4730,7 @@ Would you like to change your database location setting? Voulez-vous changer l'emplacement de votre base de données ? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3723,77 +4743,208 @@ Veuillez ouvrir un ticket sur https://github.com/Cockatrice/Cockatrice/issues Voulez-vous changer l'emplacement de votre base de données ? - - - + + + Error Erreur - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Le chemin d'accès vers votre répertoire de decks est invalide. Voulez-vous redéfinir le chemin d'accès ? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Le chemin d'accès vers votre répertoire d'illustrations de cartes est invalide. Voulez-vous redéfinir le chemin d'accès ? - + Settings Paramètres - + + Search settings... + + + + + OK + + + + General Général - + Appearance Apparence - + User Interface Interface utilisateur - + Storage - + Card Sources Origine de carte - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Son - + Shortcuts Raccourcis + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check Vérification de la mise à jour des cartes - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. @@ -3802,27 +4953,27 @@ Choisissez comment vous souhaitez exécuter le programme de mise à jour de la b Vous pouvez toujours modifier ce comportement dans l'onglet « Général » des paramètres. - + Run in foreground S'exécuter en tâche principale - + Run in background S'exécuter en tâche de fond - + Run in background and always from now on S'exécuter en tâche de fond, et toujours le faire à partir de maintenant - + Don't prompt again and don't run Ne pas demander à nouveau et ne pas exécuter - + Don't run this time Ne pas exécuter cette fois @@ -3830,17 +4981,17 @@ Vous pouvez toujours modifier ce comportement dans l'onglet « Général » DlgTipOfTheDay - + Next suivant - + Previous précédent - + Tip of the Day Conseil du jour @@ -3848,166 +4999,176 @@ Vous pouvez toujours modifier ce comportement dans l'onglet « Général » DlgUpdate - + Current release channel Branche logicielle actuelle - + Reinstall Réinstaller - + Cancel Download Annuler le téléchargement - + Open Download Page Ouvrir la page de téléchargement - + Check for Client Updates Vérification des mises à jour - - - - + + + + Error Erreur - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice n'as pas été construit avec le support du SSL, donc il n'est pas possible de télécharger les mises à jour automatiquement. Veuillez s'il vous plaît visiter la page de téléchargement, pour mettre à jour manuellement. - + Downloading update: %1 Téléchargement de la mise à jour : %1 - + Checking for updates... Vérification des mises à jour... - + Finished checking for updates Fin de la recherche des mises à jour - + No Update Available Pas de mises à jour disponibles - + Cockatrice is up to date! Cockatrice est à jour ! - + You are already running the latest version available in the chosen release channel. Vous avez déjà la dernière version correspondant à votre branche. - + Current version Version actuelle - + Selected release channel Branche logicielle sélectionnée - - + + Update Available Mise à jour disponible - - + + A new version of Cockatrice is available! Une nouvelle version de Cockatrice est disponible ! - - + + New version Nouvelle version - - + + Released Sortie - - + + Changelog Notes de version - + Do you want to update now? Voulez-vous mettre à jour maintenant ? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Malheureusement, la mise à jour automatique n'a pas pu trouver un téléchargement compatible. Vous allez peut-être devoir télécharger manuellement la nouvelle version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Veuillez vérifier la <a href="%1">page "releases"</a> sur notre Github et télécharger le programme pour votre système. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Erreur de mise à jour - + An error occurred while checking for updates: Une erreur est survenur pendant la vérification des mises à jour : - + An error occurred while downloading an update: Une erreur est survenue pendant le téléchargement de la mise à jour : - + Installing... Installation... - + Cockatrice is unable to open the installer. Cockatrice n'a pas pu ouvrir l'installateur. - + Try to update manually by closing Cockatrice and running the installer. Essayer de mettre à jour manuellement en fermant Cockatrice et en exécutant l'installateur. - + Download location Répertoire de téléchargement @@ -4015,17 +5176,17 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. DlgViewLog - + Clear log when closing Effacer le journal lors de la fermeture - + Copy to clipboard Charger vers le presse-papier - + Debug Log Journal de debug @@ -4263,7 +5424,7 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: Sel : @@ -4271,7 +5432,7 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. FilterBuilder - + Type your filter here Tapez votre filtre ici @@ -4279,45 +5440,88 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. FilterDisplayWidget - + Confirm Delete Confirmer la suppression - + Are you sure you want to delete the filter '%1'? Êtes-vous sûr de vouloir supprimer le filtre '%1' ? - + Delete Failed Suppression échouée - + Failed to delete filter '%1'. Échec de la suppression du filtre '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator Éjecté par l'hôte de la partie ou un modérateur - + player left the game joueur a quitté la partie - + player disconnected from server joueur déconnecté du serveur - + reason unknown raison inconnue @@ -4325,140 +5529,156 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GameSelector - - - - - - - - - + + + + + + + + + Error Erreur - + Please join the appropriate room first. Veuillez d'abord rejoindre le bon salon. - + Wrong password. Mot de passe incorrect. - + Spectators are not allowed in this game. Les spectateurs ne sont pas autorisés dans cette partie. - + The game is already full. Cette partie est déjà complète. - + The game does not exist any more. La partie n'existe plus. - + This game is only open to registered users. Cette partie n'est accessible qu'aux joueurs enregistrés. - + This game is only open to its creator's buddies. Cette partie n'est accessible qu'aux amis de son créateur. - + You are being ignored by the creator of this game. Vous avez été ignoré par le créateur de la partie. - + Join Game Rejoindre la partie - + Spectate Game Rejoindre la partie comme spectateur - + Game Information Informations sur la partie - + + Cop&y game link + + + + Join Game as Judge Joindre la partie comme juge - + Spectate Game as Judge Joindre la partie comme juge spectateur - + + Join game Rejoindre partie - - Password: - Mot de passe : + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Veuillez d'abord rejoindre le bon salon. - + &Filter games &Filtrer les jeux - + C&lear filter Effacer les fi&ltres - + C&reate C&réer - + &Join Re&joindre - + Join as judge Joindre en tant que juge - + J&oin as spectator Rej&oindre en tant que spectateur - + Join as judge spectator Joindre comme juge spectateur - + Games shown: %1 / %2 Parties affichées : %1 / %2 - + Games Parties @@ -4499,105 +5719,105 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GamesModel - + >1 day > 1 jour - + %1%2 hr short age in hours %1%2 h%1%2 h%1%2 h - + new Nouveau - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password mot de passe - + buddies only amis uniquement - + reg. users only joueurs enregistrés uniquement - + open decklists Ouvrir les listes de deck - - + + can chat peut discuter - + see hands voir les mains - + can see hands peut voir les mains - + not allowed non autorisé - + Room Salon - + Age Âge - + Description Description - - Creator - Créateur + + Host + - + Type Type - + Restrictions Restrictions - + Players Joueurs - + Spectators Spectateurs @@ -4605,158 +5825,275 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GeneralSettingsPage - + Reset all paths Réinitialiser tous les chemins - + All paths have been reset Les chemins ont été réinitialisés - - - - - - - + + + + English + Français (French) + + + + + + + + + Choose path Choisir un chemin d'accès - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Langue : - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Chemins (édition impossible en mode portable) - + Paths Chemins d’accès - + How to help with translations Comment aider avec les traductions - + Decks directory: Répertoire des decks : - + Filters directory: Répertoire des filtres : - + Replays directory: Répertoire des replays : - + Pictures directory: Répertoire des images : - + Card database: Base de données des cartes : - + Custom database directory: Répertoire de la base de données personnalisé - + Token database: Bases de données des jetons : - + Update channel Branche de la mise à jour : - + Check for client updates on startup Vérifier les mises à jour du client au démarrage - + Check for card database updates on startup Vérifier les mises à jour de la base de données des cartes au démarrage... - + Don't check Ne pas vérifier - + Prompt for update Confirmer les mises à jour - + Always update in the background Toujours mettre à jour en tâche de fond - + Check for card database updates every Vérifier les mises à jour de la base de données des cartes tous les - + days jour(s) - + Notify if a feature supported by the server is missing in my client M'avertir si une fonctionnalité supportée par le serveur est manquante sur mon client. - + Automatically run Oracle when running a new version of Cockatrice Lancer Oracle automatiquement quand Cockatrice a été mis à jour. - + Show tips on startup Afficher les astuces au démarrage. - + Last update check on %1 (%2 days ago) Dernière vérification de mise à jour le %1 (il y a %2 jours) @@ -4764,47 +6101,47 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GraveyardMenu - + &Graveyard &Cimetière - + &View graveyard &Voir le cimetière - + &Move graveyard to... &Déplacer le cimetière vers... - + &Top of library &Dessus de la bibliothèque - + &Bottom of library &Dessous de la bibliothèque - + &All players &Tous les joueurs - + &Hand &Main - + &Exile &Exil - + Reveal random card to... Révéler une carte au hasard à... @@ -4898,59 +6235,353 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version.Révéler une c&arte au hasard à... + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck Créer un Nouveau Deck - + Browse Decks Parcourir les Decks - + Browse Card Database Parcourir la Base de Carte - + Browse EDHRec Parcourir EDHRec - + Browse Archidekt Parcourir Archidekt - + View Replays Voir les replays - + Quit Quitter - + Connecting... Connection... - + Connect Se connecter - + Play Jouer + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5168,273 +6799,294 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. MainWindow - - + + Player %1 Joueur %1 - + Load replay Charger replay - + About Cockatrice À propos de Cockatrice - + Version Version - + Cockatrice Webpage Page web de Cockatrice - + Project Manager: Chef de projet: - + Past Project Managers: Anciens chefs de projet: - + Developers: Développeurs : - + Our Developers Nos développeurs - + Help Develop! Aidez à développer ! - + Translators: Traducteurs : - + Our Translators Nos traducteurs - + Help Translate! Aidez à traduire ! - + Support: Assistance : - + Report an Issue Signaler un problème - + Troubleshooting Dépannage - + F.A.Q. F.À.Q. - - + + Error Erreur - + &Connect... &Connecter... - + &Disconnect &Déconnecter - + Start &local game... Démarrer une partie &locale... - + &Watch replay... &Regarder un replay... - + &Full screen &Plein écran - + &Register to server... S'enregistrer sur le serveur... - + &Restore password... &Restaurer le mot de passe... - + &Settings... &Paramètres... - + &Exit &Quitter - + A&ctions Actions - + &Cockatrice &Cockatrice - + C&ard Database B&ase de données de cartes - + &Manage sets... &Gérer les éditions... - + Edit custom &tokens... Éditer les &jetons personnalisés… - + Open custom image folder Ouvrir le dossier d'images personnalisées - + Open custom sets folder Ouvrir le dossier d'éditions personnalisées - + Add custom sets/cards Ajouter des éditions / cartes personnalisées - + Reload card database Recharger la base de cartes - + Tabs Onglets - + &Help A&ide - + &About Cockatrice À propos de Cock&atrice - + &Tip of the Day &Conseil du jour - + Check for Client Updates Vérifier les mises à jour - + Check for Card Updates... Vérifier les mises à jour des cartes… - + Check for Card Updates (Automatic) Vérifier les mises à jour des cartes (automatiquement) - + Show Status Bar Montrer la barre de statut - + View &Debug Log Voir journal de &Debug - + Open Settings Folder Ouvrir le dossier des paramètres - + + Re-run Onboarding Wizard... + + + + Show/Hide Montrer/Cacher - + + Sign-in cancelled + + + + New Version Nouvelle version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Bravo pour avoir mis à jour Cockatrice %1 ! Oracle va maintenant se lancer pour mettre à jour votre base de données de cartes. - + Cockatrice installed Installation de Cockatrice terminée - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Félicitations ! Vous venez d'installer Cockatrice %1 ! Oracle va maintenant se lancer pour installer la base de données de cartes initiale. - + + Are you sure? + Êtes-vous sûr? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Base de données de cartes - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5443,29 +7095,29 @@ Voulez-vous la mettre à jour maintenant ? Si vous n’êtes pas sûr, ou si c'est la première fois que vous jouez, choisissez « Oui ». - - + + Yes Oui - - + + No Non - + Open settings Ouvrir les paramètres - + New sets found Nouvelles éditions détectées - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5478,22 +7130,22 @@ Code(s) d'édition(s) : %1 Voulez-vous l'(les) activer ? - + Yes, always enable - + View sets Voir les éditions - + Welcome Bienvenue - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5502,90 +7154,90 @@ Toutes les éditions de la base de données de cartes ont été activées. Pour plus d'informations sur la modification de l'ordre des éditions, la désactivation d'éditions spécifiques, et leurs effets, consultez le menu « Gérer les éditions… ». - + Information Informations - + A card database update is already running. Une mise à jour de la base de données de cartes est déjà en cours. - + Unable to run the card database updater: Impossible de lancer la mise à jour de la base de données de cartes : - + Card database update running. Une mise à jour de la base de données de cartes est en cours. - + Failed to start. The file might be missing, or permissions might be incorrect. Échec au démarrage. Le fichier peut être manquant, ou les permissions sont incorrectes. - + The process crashed some time after starting successfully. Le processus a échoué quelque temps après avoir démarré avec succès. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Temps limite dépassé. Le processus a pris trop longtemps pour répondre. La dernière fonction waitFor...() a terminé. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Une erreur a eu lieu en essayant d'écrire vers le processus. Par exemple, le processus pourrait ne pas tourner, ou il pourrait avoir son canal d'entrée fermé. - + An error occurred when attempting to read from the process. For example, the process may not be running. Une erreur a eu lieu en essayant d'écrire vers le processus. Par exemple, le processus pourrait ne pas tourner, ou il pourrait avoir son canal de sortie inactif. - + Unknown error occurred. Une erreur inconnue est arrivée. - + The card database updater exited with an error: %1 L'outil de mise à jour de la base de données de cartes s'est arrêté avec l'erreur : %1 - - - - - + + + + + Load sets/cards Charger des éditions/cartes - + Selected file cannot be found. Le fichier sélectionné n'a pas pu être trouvé. - + You can only import XML databases at this time. Il n'est actuellement possible que d'importer des bases de données XML. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Les nouvelles éditions/cartes ont été ajoutées avec succès. Cockactrice va maintenant recharger la base de données de cartes. - + Sets/cards failed to import. Échec de l'importation des éditions/cartes. @@ -5790,615 +7442,615 @@ Cockactrice va maintenant recharger la base de données de cartes. MessageLogWidget - + from play depuis le jeu - + from their graveyard depuis son cimetière - + from exile depuis l'exil - + from their hand depuis sa main - + the top card of %1's library la carte du dessus de la bibliothèque de %1 - + the top card of their library le carte du dessus de sa bibliothèque - + from the top of %1's library à partir du dessus de la bibliothèque de %1 - + from the top of their library du dessus de sa bibliothèque - + the bottom card of %1's library la carte du dessous de la bibliothèque de %1 - + the bottom card of their library la carte du dessous de sa bibliothèque - + from the bottom of %1's library à partir du dessous de la bibliothèque de %1 - + from the bottom of their library du dessous de sa bibliothèque - + from %1's library de la bibliothèque de %1 - + from their library depuis sa bibliothèque - + from sideboard depuis sa réserve - + from the stack depuis la pile - + from custom zone '%1' depuis la zone personnalisée '%1' - + %1 is now keeping the top card %2 revealed. %1 garde maintenant la carte du dessus de sa bibliothèque %2 révélée. - + %1 is not revealing the top card %2 any longer. %1 ne révèle plus la carte du dessus de sa bibliothèque %2. - + %1 can now look at top card %2 at any time. %1 peut maintenant regarder la carte du dessus de sa bibliothèque %2 à n'importe quel moment. - + %1 no longer can look at top card %2 at any time. %1 ne peut plus regarder la carte du dessus de sa bibliothèque %2 à n'importe quel moment. - + %1 attaches %2 to %3's %4. %1 attache %2 à %4 de %3. - + %1 has conceded the game. %1 a concédé la partie. - + %1 has unconceded the game. %1 reste dans la partie. - + %1 has restored connection to the game. %1 est revenu dans la partie. - + %1 has lost connection to the game. %1 a perdu la connexion à la partie. - + %1 points from their %2 to themselves. %1 se cible avec son %2. - + %1 points from their %2 to %3. %1 cible %3 avec son %2. - + %1 points from %2's %3 to themselves. %1 se cible avec %3 de %2. - + %1 points from %2's %3 to %4. %1 cible %4 avec %3 de %2. - + %1 points from their %2 to their %3. %1 cible de son %3 vers son %2. - + %1 points from their %2 to %3's %4. %1 cible %3 de %4 avec %2. - + %1 points from %2's %3 to their own %4. %1 cible son %4 avec %3 de %2. - + %1 points from %2's %3 to %4's %5. %1 cible %5 de %4 avec %3 de %2. - + %1 creates a face down token. %1 crée un jeton face-cachée. - + %1 creates token: %2%3. %1 crée un jeton %2%3. - + %1 has loaded a deck (%2). %1 a chargé un deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 a chargé un deck avec %2 cartes en réserve (%3). - + %1 destroys %2. %1 détruit %2. - + a card une carte - + %1 gives %2 control over %3. %1 donne le contrôle de %2 à %3. - + %1 puts %2 into play%3 face down. %1 met %2 en jeu%3 face cachée. - + %1 puts %2 into play%3. %1 met %2 en jeu %3. - + %1 puts %2%3 into their graveyard face down. %1 met %2%3 dans leur cimetière face cachée. - + %1 puts %2%3 into their graveyard. %1 met %2%3 dans son cimetière. - + %1 exiles %2%3 face down. %1 exile %2%3 face cachée. - + %1 exiles %2%3. %1 exile %2%3. - + %1 moves %2%3 to their hand. %1 met %2%3 dans sa main. - + %1 puts %2%3 into their library. %1 met %2%3 dans sa bibliothèque. - + %1 puts %2%3 onto the bottom of their library. %1 met %2%3 au-dessous de sa bibliothèque. - + %1 puts %2%3 on top of their library. %1 met %2%3 au-dessus de sa bibliothèque. - + %1 puts %2%3 into their library %4 cards from the top. %1 met %2%3 dans sa bibliothèque %4 cartes depuis le dessus - + %1 moves %2%3 to sideboard. %1 met %2%3 à sa réserve. - + %1 plays %2%3 face down. %1 joue %2%3 face cachée. - + %1 plays %2%3. %1 joue %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 déplace %2%3 vers la zone personnalisée %4 face cachée. - + %1 moves %2%3 to custom zone '%4'. %1 déplace %2%3 vers la zone personnalisée '%4'. - + %1 tries to draw from an empty library %1 essaie de piocher dans une bibliothèque vide - + %1 draws %2 card(s). %1 pioche %2 carte.%1 pioche %2 cartes.%1 pioche %2 carte(s). - + %1 is looking at %2. %1 regarde %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 regarde la %4 %3 carte %2.%1 regarde les %4 %3 cartes %2.%1 regarde la / les %4 %3 carte(s) %2. - + bottom dessous - + top dessus - + %1 turns %2 face-down. %1 retourne %2 face cachée. - + %1 turns %2 face-up. %1 retourne %2 face visible. - + The game has been closed. La partie a été fermée. - + The game has started. La partie commence. - + You are flooding the game. Please wait a couple of seconds. Vous floodez la partie. Veuillez patienter quelques secondes. - + %1 has joined the game. %1 a rejoint la partie. - + %1 is now watching the game. %1 est maintenant spectateur. - + You have been kicked out of the game. Vous avez été expulsé de la partie. - + %1 has left the game (%2). %1 a quitté la partie (%2). - + %1 is not watching the game any more (%2). %1 n'observe plus la partie (%2). - + %1 is not ready to start the game any more. %1 n'est plus prêt à démarrer la partie. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 mélange son deck et pioche une nouvelle main de %2 carte.%1 mélange sa bibliothèque et pioche une nouvelle main de %2 cartes.%1 mélange sa bibliothèque et pioche une nouvelle main de %2 carte(s). - + %1 shuffles their deck and draws a new hand. %1 mélange sa bibliothèque et pioche une nouvelle main. - + You are watching a replay of game #%1. Vous regardez un replay de la partie n° %1. - + %1 is ready to start the game. %1 est prêt à démarrer la partie. - + cards an unknown amount of cards cartes - + %1 card(s) a card for singular, %1 cards for plural %1 carte%1 cartes%1 carte(s) - + %1 lends %2 to %3. %1 prêtes %2 à %3. - + %1 reveals %2 to %3. %1 révèle %2 à %3. - + %1 reveals %2. %1 révèle %2. - + %1 randomly reveals %2%3 to %4. %1 révèle au hasard %2%3 à %4. - + %1 randomly reveals %2%3. %1 révèle au hasard %2%3. - + %1 peeks at face down card #%2. %1 regarde furtivement la carte face cachée n° %2. - + %1 peeks at face down card #%2: %3. %1 regarde furtivement la carte face cachée n° %2 : %3. - + %1 reveals %2%3 to %4. %1 révèle %2%3 à %4. - + %1 reveals %2%3. %1 révèle %2%3. - + %1 reversed turn order, now it's %2. %1 a inversé l'ordre des tours, c'est maintenant %2. - + reversed inversé - + normal normal - + Heads Face - + Tails Pile - + %1 flipped a coin. It landed as %2. %1 a lancé une pièce. Il a fait %2. - + %1 rolls a %2 with a %3-sided die. %1 a fait %2 avec un dé %3 faces. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 lances %2 pièces. Il y'a %3 faces et %4 piles. - + %1 rolls a %2-sided dice %3 times: %4. %1 lances un dé à %2-faces %3 fois: %4. - + %1's turn. Tour de %1 - + %1 sets annotation of %2 to %3. %1 met l'annotation %3 à %2. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 met les marqueurs %2 à %3 (%4%5). - + %1 sets %2 to not untap normally. %2 de %1 ne se dégagera pas lors de l'étape de dégagement. - + %1 sets %2 to untap normally. %2 de %1 se dégagera lors de l'étape de dégagement. - + %1 removes the PT of %2. %1 retire la F/E de %2. - + %1 changes the PT of %2 from nothing to %4. %1 met la F/E de %2 à %4. - + %1 changes the PT of %2 from %3 to %4. %1 passe la F/E de %2 de %3 à %4. - + %1 has locked their sideboard. %1 a verrouillé sa réserve. - + %1 has unlocked their sideboard. %1 a déverrouillé sa réserve. - + %1 taps their permanents. %1 engage ses permanents. - + %1 untaps their permanents. %1 dégage ses permanents. - + %1 taps %2. %1 engage %2. - + %1 untaps %2. %1 dégage %2. - + %1 shuffles %2. %1 mélange %2. - + %1 shuffles the bottom %3 cards of %2. %1 mélange les %3 cartes du dessous de la bibliothèque de %2. - + %1 shuffles the top %3 cards of %2. %1 mélange les %3 cartes du dessus de la bibliothèque de %2. - + %1 shuffles cards %3 - %4 of %2. %1 mélange les cartes %3 - %4 de %2. - + %1 unattaches %2. %1 détache %2. - + %1 undoes their last draw. %1 annule sa dernière pioche. - + %1 undoes their last draw (%2). %1 annule sa dernière pioche (%2). @@ -6406,115 +8058,125 @@ Cockactrice va maintenant recharger la base de données de cartes. MessagesSettingsPage - + Word1 Word2 Word3 Mot1 Mot2 Mot3 - + Add New Message Ajouter un message - + Edit Message Éditer le message - + Remove Message Supprimer le message - + Add message Ajouter message - - + + Message: Message : - + Edit message Éditer le message - + Chat settings Paramètres du chat - + Custom alert words Mots d'alertes personnalisées - + Enable chat mentions Activer les mentions dans le chat - + Enable mention completer Activer les mentions dans le chat - + In-game message macros Macros de message en jeu - + How to use in-game message macros Comment utiliser les macro pour les messages en jeu - + Ignore chat room messages sent by unregistered users Ignorer les messages dans le chat principal envoyés par des joueurs non enregistrés. - + Ignore private messages sent by unregistered users Ignorer les messages privés envoyés par des joueurs non enregistrés. - + Ignore private messages sent by non-buddy users - - + + Invert text color Inverser la couleur du texte - + Enable desktop notifications for private messages Activer les notifications de bureau pour les messages privés. - + Enable desktop notification for mentions Activer les notifications de bureau pour les mentions. - + Enable room message history on join Activer l'historique des messages du chat principal dès la connexion - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (La couleur est hexadécimale) - + Separate words with a space, alphanumeric characters only Séparer les mots avec un espace, seulement les caractères alphanumériques @@ -6522,42 +8184,42 @@ Cockactrice va maintenant recharger la base de données de cartes. MoveMenu - + Move to Déplacer vers - + &Top of library in random order &Dessus de la bibliothèque dans un ordre aléatoire - + X cards from the top of library... X cartes du dessus de la bibliothèque... - + &Bottom of library in random order &Dessous de la bibliothèque dans un ordre aléatoire - + T&able Tableau - + &Hand &Main - + &Graveyard &Cimetière - + &Exile &Exil @@ -6636,148 +8298,134 @@ Cockactrice va maintenant recharger la base de données de cartes. PaletteEditorDialog - - + + Reset Réinitialiser - - - Apply - Appliquer - - - - - Save && Apply - Sauvegarder && Appliquer - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active Actif - + Disabled Désactivé - + Inactive Inactif - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6900,30 +8548,21 @@ Cockactrice va maintenant recharger la base de données de cartes. Phase de fin de tour - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - fr - - PlayerActions - - - - + + + + grave cimetière - - - - + + + + exile exile @@ -7042,16 +8681,272 @@ Cockactrice va maintenant recharger la base de données de cartes. PlayerMenu - + Player "%1" Joueur "%1" - + &Counters &Marqueurs + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7076,12 +8971,12 @@ Cette option fait que vous ne verrez que l'impression par défaut pour chaq PrintingSelector - + Display Navigation Buttons Afficher les boutons de navigation - + Printing Selector Sélection d'impression @@ -7089,25 +8984,65 @@ Cette option fait que vous ne verrez que l'impression par défaut pour chaq PrintingSelectorCardOverlayWidget - + Preference Préférence - + Pin Printing Épingler Imprimer - + Unpin Printing Désépingler Imprimer - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards Afficher les cartes associées + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7138,28 +9073,28 @@ Cette option fait que vous ne verrez que l'impression par défaut pour chaq PrintingSelectorCardSortingWidget - + Alphabetical Alphabétiquement - + Preference Préférences - + Release Date Date de Sortie - - + + Descending Descendant - + Ascending Ascendant @@ -7175,95 +9110,103 @@ Cette option fait que vous ne verrez que l'impression par défaut pour chaq PtMenu - + Power / toughness Force / Endurance - + &Increase power &Augmenter la force - + &Decrease power &Diminuer la force - + I&ncrease toughness A&ugmenter l'endurance - + D&ecrease toughness D&iminuer l'endurance - + In&crease power and toughness Au&gmenter la force et l'endurance - + Dec&rease power and toughness Dim&inuer la force et l'endurance - + Increase power and decrease toughness Augmenter la force et diminuer l'endurance - + Decrease power and increase toughness Diminuer la force et augmenter l'endurance - + Set &power and toughness... Changer la &force et l'endurance... - + Reset p&ower and toughness Réinitialiser la f&orce et l'endurance + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Services - + Hide %1 Cacher %1 - + Hide Others Cacher les autres - + Show All Montrer tout - + Preferences... Préférences... - + Quit %1 Quitter %1 - + About %1 À propos %1 @@ -7271,17 +9214,17 @@ Cette option fait que vous ne verrez que l'impression par défaut pour chaq QObject - + Cockatrice card database (*.xml) Base de données de cartes Cockatrice (*.xml) - + All files (*.*) Tous les fichiers (*.*) - + Cockatrice replays (*.cor) Replays Cockatrice (*.cor) @@ -7301,17 +9244,22 @@ Cette option fait que vous ne verrez que l'impression par défaut pour chaq Jetons - + + Maybeboard + + + + Overwrite Existing File? Écraser le fichier existant ? - + A .cod version of this deck already exists. Overwrite it? Une version en fichier .cod existe déjà pour ce deck. L'écraser ? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7328,7 +9276,7 @@ Vous devrez utiliser le Gestionnaire d'extensions, accessible via : Base de Êtes-vous sûr de vouloir activer cette option ? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7345,100 +9293,110 @@ Vous pouvez également utiliser le gestionnaire de d'extension pour personn Êtes-vous sûr de vouloir désactiver cette option ? - + Confirm Change Confirmer le changement + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Sauvegarder - + Save All Sauvegarder tout - + Open Ouvrir - + &Yes &Oui - + Yes to &All Dire oui à tout - + &No &Non - + N&o to All Dire non à tout - + Abort Abandonner - + Retry Réessayer - + Ignore Ignorer - + Close Fermer - + Cancel Annuler - + Discard Effacer - + Help Aide - + Apply Appliquer - + Reset Réinitialiser - + Restore Defaults Remettre à zéro @@ -7446,121 +9404,189 @@ Vous pouvez également utiliser le gestionnaire de d'extension pour personn QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Nom - + ID ID - + Upload time Temps de chargement + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Nom - + Players Joueurs - + Keep Garder - + Time started Le temps a commencé - + Duration (sec) Durée (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7602,37 +9628,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Salons - + Joi&n &Rejoindre - + Room Salon - + Description Description - + Permissions Permissions - + Players Joueurs - + Games Parties @@ -7640,7 +9666,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay Dire @@ -7648,32 +9674,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Choisir une action de la table - + Hit the key/combination of keys you want to set for this action Choisissez la combinaison de touches que vous voulez utiliser pour cette action - + Invalid key Touche invalide - + Shortcut already in use by: Raccourci déjà utilisé par : - + Clear Effacer - + Restore default Options par défaut @@ -7706,56 +9732,92 @@ Fine-tune individual colours in the grid afterwards. Date de sortie + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Réinitialiser tous les raccourcis - + Do you really want to restore all default shortcuts? Êtes-vous sûr de vouloir réinitialiser tous les raccourcis ? - + Clear all default shortcuts Effacer tous les raccourcis par défaut - + Do you really want to clear all shortcuts? Êtes-vous sûr de vouloir effacer tous les raccourcis ? - + Section: Section : - + Action: Action : - + Shortcut: Raccourci : - + + Shortcut editor + + + + How to set custom shortcuts Comment assigner des raccourcis personnalisés - + Clear all shortcuts Effacer tous les raccourcis - + Search by shortcut name Rechercher par nom de raccourci @@ -7810,12 +9872,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard &Réserve - + &View sideboard &Voir la réserve @@ -7823,27 +9885,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Activer les &sons - + Current sounds theme: Thème sonore actuel: - + Test system sound engine Tester les effets sonores - + Sound settings Paramètres de son - + Master volume Volume principal @@ -7851,48 +9913,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended La saison des spoilers est terminée - + Deleting spoiler.xml. Please run Oracle Suppression de spoiler.xml. S'il vous plaît exécuter Oracle - - + + Spoilers download failed Échec du téléchargement des spoilers - + No internet connection Pas de connexion Internet - + Error Erreur - + Spoilers already up to date Spoilers déjà à jour - + No new spoilers added Aucun nouveau spoilers ajouté - + Spoilers have been updated! Les spoilers ont été mis à jour! - + Last change: Dernier changement: @@ -7900,157 +9962,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default Défaut - + No reply received from the release update server. Aucune réponse reçue de la part du serveur de mise à jour. - + Invalid reply received from the release update server. Réponse invalide reçue de la part du serveur de mise à jour. - + No reply received from the tag update server. Aucune réponse reçue de la part du serveur de mise à jour des tags. - + Invalid reply received from the tag update server. Réponse invalide reçue de la part du serveur de mise à jour des tags. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) Jour(s) @@ -8058,17 +10133,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List Ajouter à la liste d'amis - + Add to Ignore List Ajouter à la liste noire - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account Compte @@ -8189,118 +10280,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. Desc. - - + + AND ET - - + + Require ALL selected colors TOUTES les couleurs sélectionnées obligatoires - - + + Deck name... Nom du deck... - - + + Owner... Propriétaire... - - + + Packages - - + + Advanced Filters Filtres avancés - + Bracket: Catégorie : - - + + Any Tous - - + + Contains card... Contient la carte... - - + + Commander... Commandant... - - + + Tag... Tag... - - + + Deck Size Taille du deck - + Cards: Cartes : - - + + Asc. Asc. - + Sort by: Trier par : - + Filter by: Filtrer par : - + Display Settings Options d'affichage - - + + Search Rechercher - - + + Formats Formats @@ -8313,42 +10404,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: Carte: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8361,52 +10452,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info Infos de la carte - + Deck Bibliothèque - + Filters Filtres - + &View &Voir - + Card Database Base de données de cartes - + Printing Imprimer - + Visible Visible - + Floating Flottant - + Reset layout Réinitialiser l'interface - + Deck: %1 Deck : %1 @@ -8414,49 +10505,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 Deck visuel : %1 - + &Visual Deck Editor &Éditeur de deck visuel - - + + Card Info Infos de la carte - - + + Deck Deck - + &View &Voir - + Printing Imprimer - + Visible Visible - + Floating Flottant - + Reset layout Réinitialiser l'interface @@ -8464,22 +10555,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View Vue de deck visuel - + Visual Database Display Affichage visuel de la base de données - + Deck Analytics Analyse de deck - + Sample Hand Exemple de main @@ -8487,139 +10578,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Système de fichier local - + Server deck storage Serveur de stockage de deck - - + + Open in deck editor Ouvrir dans l'éditeur de deck - + Rename deck or folder Renommer le deck ou le dossier - + Upload deck Envoyer deck - + Download deck Télécharger deck - - - - + + + + New folder Nouveau dossier - - + + Delete Supprimer - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Ouvrir le dossier des decks - + Rename local folder Renommer le dossier local - + Rename local file Renommer le fichier local - + New name: Nouveau nom : - - - - + + + + + + + Error Érreur - + Rename failed Renommage échoué - - + + + Invalid deck file Fichier de deck invalide - + Enter deck name Entrez le nom du deck - + This decklist does not have a name. Please enter a name: Ce deck n'a pas de nom. Veuillez entrer un nom: - + Unnamed deck Deck sans nom - + Failed to upload deck to server Erreur lors de l'importation du deck sur le serveur - + Delete local file Supprimer fichier local - + Are you sure you want to delete the selected files? Êtes-vous certain de vouloir supprimer les fichiers sélectionnés ? - + Delete remote decks Supprimer les decks dinstants - + Are you sure you want to delete the selected decks? Êtes-vous certain de vouloir supprimer les decks sélectionnés ? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Nom du nouveau dossier : - + Deck Storage Stockage de deck @@ -8627,20 +10816,271 @@ Veuillez entrer un nom: TabDeckStorageVisual - + Visual Deck Storage Stockage visuel de deck - + Error Erreur - + Could not open deck at %1 N'a pas pu ouvrir le deck à %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8653,27 +11093,27 @@ Veuillez entrer un nom: TabEdhRecMain - + &Cards &Cartes - + Top Commanders Commandants populaires - + Tags Étiquettes - + Search for a card ... Rechercher une carte ... - + Search Rechercher @@ -8686,191 +11126,207 @@ Veuillez entrer un nom: TabGame - - - + + + Replay Replay - - + + Game Partie - - + + Player List Liste des joueurs - - + + + Invite + + + + + Card Info Infos de la carte - - + + Messages Messages - - + + Replay Timeline Historique des replays - + &Phases &Phases - + &Game &Partie - + Next &phase Étape &suivante - + Next phase with &action Étape suivante avec &action - + Next &turn &Tour suivant - + Reverse turn order Inverser l'ordre des tours - + &Remove all local arrows &Retirer toutes les flèches locales - + Rotate View Cl&ockwise Pivoter la vue dans le sens h&oraire - + Rotate View Co&unterclockwise Pivoter la vue dans le sens &anti-horaire - + Game &information &Informations sur la partie - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede Annuler la &concession - - - + + + &Concede &Concéder - + &Leave game &Quitter la partie - + C&lose replay Fermer &le replay - + &Focus Chat &Focus sur le chat - + &Say: &Dire : - + Selected cards Cartes sélectionnées - + &View &Voir - + Visible Visible - + Floating Flottant - + Reset layout Réinitialiser l'interface - + Concede Concéder - + Are you sure you want to concede this game? Êtes-vous sûr de vouloir concéder la partie ? - + Unconcede Rester dans la partie - + You have already conceded. Do you want to return to this game? Vous avez déjà concédé la partie. Voulez-vous la reprendre ? - + Leave game Quitter la partie - + Are you sure you want to leave this game? Êtes-vous sûr de vouloir quitter la partie ? - + A player has joined game #%1 Un joueur a rejoint la partie #%1. - + %1 has joined the game %1 a rejoint la partie. - + You have been kicked out of the game. Vous avez été expulsé de la partie. @@ -8886,158 +11342,158 @@ Veuillez entrer un nom: TabLog - + Logs Journaux - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Durée;Expéditeur;IPExpéditeur;Message;IDCible;NomCible - + Room Logs Journaux du salon - + Game Logs Journaux de la partie - + Chat Logs Journaux du chat - - + + Error Erreur - + You must select at least one filter. Vous devez sélectionner au moins un filtre. - + You have to select a valid number of days to locate. Vous devez sélectionner un nombre valide de jours à identifier. - + Username: Nom d'utilisateur: - + IP Address: Adresse IP : - + Game Name: Nom de la partie : - + GameID: ID de la partie : - + Message: Message : - + Main Room Salon principal - + Game Room Salon de jeu - + Private Chat Chat privé - + Past X Days: Les X derniers jours : - + Today Aujourd'hui - + Last Hour La dernière heure - + Maximum Results: Résultats maximum : - + At least one filter is required. The more information you put in, the more specific your results will be. Au moins un filtre est requis. Plus vous entrez d'informations, meilleurs seront les résultats. - + Get User Logs Obtenir les journaux de l'utilisateur - + Clear Filters Effacer les filtres - + Filters Filtres - + Log Locations Emplacement des journaux - + Date Range Intervalle de dates - + Maximum Results Résultats maximum - - + + Message History Historique des messages - + Failed to collect message history information. Impossible de récupérer l'historique des messages. - + There are no messages for the selected filters. Il n'y a pas de messages pour les filtres sélectionnés. @@ -9045,218 +11501,558 @@ Plus vous entrez d'informations, meilleurs seront les résultats. TabMessage - + Private &chat &Chat privé - + &Leave &Quitter - + %1 - Private chat %1 - Chat privé - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Cet utilisateur vous ignore, il ne peut pas voir vos messages dans le chat principal et vous ne pouvez pas rejoindre ses parties. - + Private message from Message privé de - + %1 has left the server. %1 a quitté le serveur. - + %1 has joined the server. %1 a rejoint le serveur. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Système de fichier local - + Server replay storage Stockage du serveur de replays - - + + Watch replay Voir replay - + Rename Renommer - - + + New folder Nouveau fichier - - + + Delete Supprimer - + Open replays folder Ouvrir le dossier des Replays - + Download replay Télécharger replay - + Toggle expiration lock Changer le verrouillage de l'expiration - + Get replay share code Obtenir le code de partage du replay - - + + Look up replay by share code Regarder un replay depuis un code de partage - + Rename local folder Renommer le dossier local - + Rename local file Renommer le fichier local - + New name: Nouveau nom: - + Error Erreur - + Rename failed Renommage échoué - + Name of new folder: Nom du nouveau fichier: - + Delete local file Supprimer fichier local - + Are you sure you want to delete the selected files? Êtes-vous certain de vouloir supprimer les fichiers sélectionnés? - + Are you sure you want to delete the selected replays? Êtes-vous certain de vouloir supprimer cet enregistrement? - + Failed to get code Echec d'obtention du code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Soit ce serveur ne prend pas en charge le partage de replay, soit il ne vous autorise pas à partager des replays. - - - + + + Failed Échec - + Could not get replay code Impossible d'obtenir le code de replay - + Replay Share Code Code de Partage de Replay - + Others can use this code to add the replay to their list of remote replays: %1 D'autres peuvent utiliser ce code pour ajouter la replay à leur liste de replay distantes :%1 - + Copy to clipboard Copier vers le presse-papier - + Replay share code Code de partage de replay - + Replay code found Code de replay trouvé - + Replay was added, or you already had access to it. Le replay a été ajoutée, ou vous y aviez déjà accès. - + Replay code not found Code de replay non trouvé - + Failed to submit code Échec de l'envoi du code - + Unexpected error Erreur inattendue - + Delete remote replay Supprimer le replay distant @@ -9266,65 +12062,478 @@ Plus vous entrez d'informations, meilleurs seront les résultats.Replays de parties + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Dire : - + Chat Chat - + &Room &Salon - + &Leave room &Quitter le salon - + &Clear chat Effacer le &Chat - + Chat Settings... Paramètres du chat... - + mentioned you. vous a mentionné. - + Click to view Cliquer pour voir - + You are flooding the chat. Please wait a couple of seconds. Vous floodez le chat. Veuillez patienter quelques secondes. @@ -9332,35 +12541,35 @@ Plus vous entrez d'informations, meilleurs seront les résultats. TabServer - + Server Serveur - - - - + + + + Error Erreur - + Failed to join the server room: it doesn't exist on the server. Impossible de rejoindre le salon: il n'existe pas sur le serveur - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Le serveur considère que vous êtes dans le salon mais votre client est incapable de l'afficher. Essayez en relançant votre client. - + You do not have the required permission to join this server room. Vous n'avez pas la permission requise pour rejoindre ce salon. - + Failed to join the server room due to an unknown error: %1. Impossible de rejoindre le salon à cause d'un erreur inconnue: %1 @@ -9368,97 +12577,173 @@ Plus vous entrez d'informations, meilleurs seront les résultats. TabSupervisor - + Deck Editor Éditeur de deck - + Visual Deck Editor Éditeur de deck visuel - + EDHRec EDHrec - + Archidekt Archidekt - + Home Accueil - + &Visual Deck Storage Stockage &visuel de decks - + Visual Database Display Affichage visuel de la base de données - + Server Serveur - + Account Compte - + Deck Storage Stockage de deck - + Game Replays Replays de parties - + Administration Administration - + Logs Journaux - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Êtes-vous sûr ? - + There are still open games. Are you sure you want to quit? Il y a encore des parties en cours. Êtes-vous sûr de vouloir quitter ? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Cliquer pour regarder - + Your buddy %1 has signed on! Votre ami %1 s'est inscrit ! - + Unknown Event Événement inconnu - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9469,39 +12754,39 @@ Ce message peut signifier qu'il y a une nouvelle version de Cockatrice disp Pour mettre à jour votre client, allez dans « Aide -> Vérifier les mises à jour ». - + Idle Timeout Temporisation inactive - + You are about to be logged out due to inactivity. Vous allez d'être déconnecté en raison de l'inactivité. - + Promotion Promotion - + You have been promoted. Please log out and back in for changes to take effect. Vous avez été promu modérateur. Veuillez vous reloguer pour que les changements prennent effet. - + Warned Averti - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. You avez reçu un avertissement pour la raison : %1. Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre vous. Si vous avez des questions, veuillez contacter un modérateur en privé. - + You have received the following message from the server. (custom messages like these could be untranslated) Vous avez reçu le message suivant du serveur. @@ -9521,6 +12806,34 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Affichage visuel de la base de données + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9535,6 +12848,65 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Impossible d'analyser le Deck. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9606,57 +12978,52 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9664,216 +13031,266 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre UserContextMenu - + User &details &Détails utilisateur - + Private &chat &Chat privé - + Show this user's &games Voir les parties du &joueur - + + View this user's &public decks + + + + Add to &buddy list Ajouter à la liste d'&amis - + Remove from &buddy list Retirer de la liste d'&amis - + Add to &ignore list Ajouter à la l&iste noire - + Remove from &ignore list Retirer de la l&iste noire - + Kick from &game Exclure de la &partie - + + Report user + + + + Warn user Avertir le joueur - + View user's war&n history Voir l'historique des avertisseme&nts du joueur - + Ban from &server Bannir du &serveur - + View user's &ban history Voir l'historique de &bannissement du joueur - + &Promote user to moderator &Promouvoir d'utilisateur à modérateur - + Dem&ote user from moderator Rétr&ograder de modérateur à utilisateur - + Promote user to &judge Promouvoir utilisateur à &arbitre - + Demote user from judge Destituer arbitre - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes Voir les notes d'administrateur - - + Investigate user + + + + + + Error Erreur - + This user does not exist. Cet utilisateur n'existe pas. - + You are being ignored by %1 and can't see their games. Vous avez été ignoré par %1 et ne pouvez pas voir ses parties. - + Could not get %1's games. Impossible d'obtenir les parties de %1. - + %1's games Parties de %1 - - - + + + Ban History Historique des bannissements - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Heure de bannissement;Modérateur;Durée de bannissement;Raison du bannissement;Raison visible - + User has never been banned. Le joueur n'a jamais été banni. - + Failed to collect ban information. Impossible de collecter les informations de bannissement. - - - + + + Warning History Historique des avertissements - + Warning Time;Moderator;User Name;Reason Heure d'avertissement;Modérateur;Nom d'utilisateur;Raison - + User has never been warned. Le joueur n'a jamais reçu d'avertissement. - + Failed to collect warning information. Impossible de collecter les informations d'avertissements. - + Failed to get admin notes. Impossible de récupérer les notes d'administrateur. - - + + Success Succès - + Successfully promoted user. L'utilisateur a été promu avec succès. - + Successfully demoted user. L'utilisateur a été rétrogradé avec succès. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Échec - + Failed to promote user. L'utilisateur n'a pas été promu. - + Failed to demote user. L'utilisateur n'a pas été rétrogradé. - + Copy hash to clipboard Copier le hash - + Remove this user's messages Supprimer les messages de cet utilisateur @@ -9911,61 +13328,61 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Administrateur - + Moderator Modérateur - + Registered user Joueur enregistré - - + + Unregistered user Joueur non enregistré - + Judge Arbitre - + Unknown Inconnu - + The entered password does not match your account. Le mot de passe est incorrect - - - - + + + + Information Information - + User information updated. Informations de l'utilisateur mises à jour. - - - - - - + + + + + - - - + + + + Error Erreur @@ -9989,283 +13406,292 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n année.%n années.%n année(s). - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n jour %20%10%n jours %20%10%n jour(s) %20 - + Enter Password Saisir le mot de passe - + Password verification is required in order to change your email address La vérification du mot de passe est requise pour changer votre adresse e-mail - - - + + + An error occurred while trying to update your user information. Une erreur est survenue lors de la mise à jour de vos informations personnelles. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Ce serveur ne vous permet pas de mettre à jour vos informations personnelles. - + Password changed. Mot de passe changé. - + This server does not permit you to change your password. Ce serveur ne vous permet pas de changer votre mot de passe. - + The new password is too short. Le nouveau mot de passe est trop court. - + The old password is incorrect. L'ancien mot de passe est incorrect. - + Avatar updated. Avatar mis à jour. - + This server does not permit you to update your avatar. Ce serveur ne vous permet pas de mettre à jour votre avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Ce serveur ne vous permet pas de mettre à jour vos informations personnelles. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history Voir l'historique de bannissements - + Warn log - + View warning history Voir l'historique d'avertissements - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10273,223 +13699,344 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre UserInterfaceSettingsPage - + General interface settings Paramètres généraux de l'interface - + &Double-click cards to play them (instead of single-click) &Double cliquer sur les cartes pour les jouer (au lieu d'un simple clic) - + &Clicking plays all selected cards (instead of just the clicked card) &Cliquer joue toutes les cartes sélectionnées (à la place de seulement la carte cliquée) - + &Play all nonlands onto the stack (not the battlefield) by default &Jouer toutes les cartes non terrain dans la pile (pas sur le champ de bataille) par défaut - + Do not delete &arrows inside of subphases Ne pas effacer les flèches lors de sous-phases - + Close card view window when last card is removed Fermer la fenêtre d'affichage des cartes lorsque la dernière carte est supprimée - + Auto focus search bar when card view window is opened Focalise automatiquement la barre de recherche lorsque la fenêtre d'affichage des cartes est ouverte - + Annotate card text on tokens Annoter le texte des cartes sur les jetons - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Utiliser les menus détachables, permettant les clics droits sur les rendre persistants - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Paramètres des notifications - + Enable notifications in taskbar Activer les notifications dans la barre des tâches - + Notify in the taskbar for game events while you are spectating Notifier dans la barre des tâches pour les évènements des parties quand vous y êtes spectateur - + Notify in the taskbar when users in your buddy list connect Notifier dans la barre des tâches quand un ami se connecte - + Animation settings Paramètres des animations - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &Animation d'engagement / dégagement - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings Paramètres d'éditeur et de stockage de deck - + Open deck in new tab by default Ouvre le deck dans un nouvel onglet par défaut - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby Utiliser le stockage visuel de decks dans le lobby des parties - + Use selection animation for Visual Deck Storage Utilisez l'animation de sélection pour le stockage visuel des deck - + When adding a tag in the visual deck storage to a .txt deck: Lorsque vous ajoutez une étiquette dans le stockage visuel du deck à un deck stocké comme un fichier .txt : - - do nothing - ne rien faire + + Do nothing + - - ask to convert to .cod - demander à convertir en fichier .cod + + Ask to convert to .cod + - - always convert to .cod - toujours convertir en fichier .cod + + Always convert to .cod + - + Default deck editor type Type d'éditeur de deck par défaut - + Classic Deck Editor Éditeur de deck classique - + Visual Deck Editor Éditeur de deck visuel - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Paramètres du replay - + Buffer time for backwards skip via shortcut: Temps de tampon pour les sauts en arrière via un raccourci : + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 Joueurs connectés au serveur : %1 - + Users in this room: %1 Joueurs dans ce salon : %1 - + Buddies online: %1 / %2 Amis connectés : %1 / %2 - + Ignored users online: %1 / %2 Joueurs sur liste noire connectés : %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters Incrémenter tous les marqueurs de la carte - + &Untap all permanents &Dégager tous les permanents - + R&oll die... L&ancer un dé... - + Flip coin - + &Create token... &Créer un jeton... - + C&reate another token C&réer un autre jeton - + Cr&eate predefined token C&réer un jeton prédéfini @@ -10526,22 +14073,22 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... Filtre de recherche... - + Save Filter Sauvegarder le filtre - + Save all currently applied filters to a file Sauvegarder tous les filtres actuellement appliqués dans un fichier - + Enter filename... Entrer le nom du fichier... @@ -10549,72 +14096,72 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayFilterToolbarWidget - + Sort by Trier par - + Filter by Filtrer par - + Save and load filters Sauvegarder et charger les filtres - + Filter by exact card name Filtrer par nom exact de carte - + Filter by card main-type Filtrer par type principal de carte - + Filter by card sub-type Filtrer par sous-type de carte - + Filter by set Filtrer par extension - + Filter by format legality Filtrer par légalité dans le format - + Save/Load Sauvegarder/Charger - + Name Nom - + Main Type Type Principal - + Sub Type Sous-type - + Sets Éditions - + Formats Formats @@ -10716,7 +14263,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filtrer par extensions récentes @@ -10724,19 +14271,19 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplaySetFilterWidget - + Search sets... Rechercher les extensions... - - + + Mode: Exact Match Mode : correspondance exacte - - + + Mode: Includes Mode : inclure @@ -10782,28 +14329,28 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Rechercher par nom de carte (ou expression de recherche) - - + + Visual Visuel - + Loading database ... Chargement de la base de données... - + Clear all filters Effacer tous les filtres - + Table Tableau @@ -10811,43 +14358,43 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckDisplayOptionsWidget - + Group by: Grouper par : - + Change how cards are divided into categories/groups. Configurez comment les cartes sont divisées en groupes/catégories. - + Sort by: Trier par : - + Click and drag to change the sort order within the groups Cliquer et glisser pour changer l'ordre de tri dans les groupes. - + Configure how cards are sorted within their groups Configurez le mode de tri des cartes dans leurs groupes - - + + Toggle Layout: Overlap Mode d'affichage : Superposé - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) Modifier l'affichage des cartes dans les zones (i.e. superposées ou entièrement visibles). - + Toggle Layout: Flat Mode d'affichage : Plat @@ -10863,12 +14410,12 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckEditorSampleHandWidget - + Draw a new sample hand Piocher un nouvel exemple de main - + Sample hand size Taille de l'exemple de main @@ -10876,17 +14423,17 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckEditorWidget - + Type a card name here for suggestions from the database... Tapez un nom de carte ici pour des suggestions de la base de données... - + Quick search and add card Recherche et ajout rapide d'une carte - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Recherchez la correspondance la plus proche dans la base de données (avec suggestions automatiques) et ajoutez l'impression souhaitée au deck en appuyant sur Entrée @@ -10894,7 +14441,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckStorageFolderDisplayWidget - + Deck Storage Stockage de deck @@ -10902,52 +14449,57 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckStorageQuickSettingsWidget - + Show Folders Montrer les dossiers - + Show Tag Filter Montrer le filtre à étiquette - + Show Color Identity Montrer l'identité de couleur - + Show Tags On Deck Previews Montrer les étiquettes sur les aperçus de decks - + + Show Upload Time + + + + Show Banner Card Selection Option Montrer l'option de sélection de carte bannière - + Draw unused Color Identities Affiche les identités de couleurs non utilisées - + Unused Color Identities Opacity Opacité des identités de couleur inutilisées - + Deck tooltip: Aide contextuelle du deck : - + None Aucun(e) - + Filepath Chemin de fichier @@ -10955,7 +14507,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckStorageSearchWidget - + Search by filename (or search expression) Rechercher par chemin de fichier (ou expressions de recherche) @@ -10963,22 +14515,22 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) Trier par ordre alphabétique (nom de deck) - + Sort Alphabetically (Filename) Trier par ordre alphabétique (nom de fichier) - + Sort by Last Modified Trier par date de dernière modification - + Sort by Last Loaded Trier par date de dernier chargement @@ -10986,17 +14538,22 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckStorageWidget - + Loading database ... Chargement de la base de données ... - + Refresh loaded files Recharger les fichiers chargés - + + Select decks to share + + + + Visual Deck Storage Settings Paramètres du stockage visuel des decks @@ -11004,177 +14561,202 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre WarningDialog - + Which warning would you like to send? Quel avertissement voudriez-vous envoyer ? - + Redact all messages from this user in all rooms Censurer tous les messages de cet utilisateur dans toutes les salles - + &OK &OK - + &Cancel &Annuler - + Warn user for misconduct Envoyer un avertissement au joueur pour mauvaise conduite - - + + Error Erreur - + User name to send a warning to can not be blank, please specify a user to warn. Le nom d'utilisateur du joueur à avertir ne peut pas être vide, merci d'entrer un joueur à avertir. - + Warning to use can not be blank, please select a valid warning to send. L'avertissement à utiliser ne peut pas être vide, merci de sélectionner un avertissement valide à envoyer. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Déplacer l'édition sélectionnée tout en haut - + Move selected set up Déplacer l'édition sélectionnée vers le haut - + Move selected set down Déplacer l'édition sélectionnée vers le bas - + Move selected set to the bottom Déplacer l'édition sélectionnée tout en bas - - Search by set name, code, or type - Rechercher par ensemble de nom, code ou type - - - + Default order Ordre par défaut - + Restore original art priority order Restaurer l'ordre original de priorité artistique - + Enable all sets Activer toutes les éditions - + Disable all sets Désactiver toutes les éditions - + Enable selected set(s) Activer l(es) édition(s) sélectionnée(s) - + Disable selected set(s) Désactiver l(es) éditions(s) sélectionné(s) - + Deck Editor Éditeur de deck - + Use CTRL+A to select all sets in the view. Utiliser Ctrl+A pour sélectionner toutes les éditions dans la vue actuelle. - + Only cards in enabled sets will appear in the card list of the deck editor. Seules les cartes des éditions activées apparaitront dans la liste de cartes de l'éditeur de deck. - + Image priority is decided in the following order: La priorité des images est décidé dans l'ordre suivant: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki D'abord le dossier PERSONNALISÉ (%1), puis les éditions activées dans cette fenêtre (de haut en bas) - + Include cards rebalanced for Alchemy [requires restart] Inclure les cartes rééquilibrées pour Alchemy [nécessite un redémarrage] - + Card Art Images des cartes - + + Search by set name, code, type, or release date + + + + How to use custom card art Comment utiliser une illustration personnalisée de carte - + Hints Astuces - + Note Note - + Sorting by column allows you to find a set while not changing set priority. Trier par colonne vous permet de trouver une édition sans changer la priorité des éditions. - + To enable ordering again, click the column header until this message disappears. Pour trier à nouveau, cliquez sur l'entête de la colonne jusqu'à ce que ce message disparaisse. - + Use the current sorting as the set priority instead Utiliser le tri actuel comme ordre de priorité des éditions - + Sorts the set priority using the same column Sortir la priorité sur la même colonne - + Manage sets Gérer les éditions @@ -11182,93 +14764,85 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre ZoneViewWidget - + Search by card name (or search expressions) Rechercher par nom de carte (ou expression de recherche) - + Ungrouped Non groupé - + Group by Type Grouper par Types - + Group by Mana Value Grouper par Valeur de Mana - + Group by Color Grouper par Couleurs - + Unsorted Non trié - + Sort by Name Trier par Nom - + Sort by Type Trier par Type - + Sort by Mana Cost Trier par Coût de Mana - + Sort by Colors Trier par Couleurs - + Sort by P/T Trier par F/E - + Sort by Set Trier par extension - + shuffle when closing mélanger en quittant - + pile view vue de la pile - - i18n - - - English - Français (French) - - main - + Connect on startup Connecter au démarrage - + Debug to file Débuguer dans journal @@ -12052,6 +15626,16 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Graveyard Cimetière + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_fr.ts b/oracle/translations/oracle_fr.ts index 590a0b879..6e384576f 100644 --- a/oracle/translations/oracle_fr.ts +++ b/oracle/translations/oracle_fr.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Introduction - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Cet assistant va importer la liste des éditions, cartes et jetons qui seront utilisés par Cockatrice. - + Interface language: Langage de l'interface : - + Version: Version : @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Choix du fichier source - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Veuillez spécifier une source compatible pour la liste d'éditions et de cartes. Vous pouvez spécifier une URL qui sera utilisée pour télécharger un fichier ou utiliser un fichier existant sur votre ordinateur. - + Download URL: URL de téléchargement : - + Local file: Fichier local : - + Restore default URL Restaurer l'URL par défaut - + Choose file... Choisissez un fichier... - + Load sets file Charger une liste d'éditions - + Sets file (%1) Sets JSON file (%1) Choisir le fichier (%1) - - - - - - - + + + + + Error Erreur - + The provided URL is not valid. L'URL fournie n'est pas valable - - Downloading (0MB) - Téléchargement en cours (0MB) - - - + Please choose a file. Choisissez un fichier. - + Cannot open file '%1'. Impossible d'ouvrir le fichier '%1'. - - Downloading (%1MB) - Téléchargement (%1MB) - - - + Network error: %1. Erreur réseau : %1. - + Parsing file Traitement du fichier. - + Xz extraction failed. L'extraction du zip à échoué. - + Sorry, this version of Oracle does not support xz compressed files. Désolé, cette version d'Oracle ne supporte pas les fichiers zip. - + Failed to open Zip archive: %1. Impossible d'ouvrir l'archive zip: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Extraction zip échouée: l'archive zip contient plus qu'un fichier. - + Zip extraction failed: %1. L'extraction du zip a échoué : %1. - + Sorry, this version of Oracle does not support zipped files. Désolé, cette version d'Oracle ne supporte pas les fichiers zip. - + Failed to interpret downloaded data. Échec lors de l'interprétation des données téléchargées. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Voulez-vous télécharger le fichier non compressé à la place ? - + + The file was retrieved successfully, but it does not contain any sets data. Le fichier a été trouvé, mais ne contient aucune éditions. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Enregistrer la base de données des spoilers - + XML; spoiler database (*.xml) XML ; base de données des spoilers (*.xml) - + spoiler spoiler - + Spoilers import Importation des spoilers - + Please specify a compatible source for spoiler data. Veuillez spécifier une source compatible pour les spoilers. - + Download URL: URL de téléchargement : - + Local file: Fichier local: - + Restore default URL Restaurer l'URL par défaut - + Choose file... Choisissez un fichier... - + The spoiler database will be saved at the following location: La base de données des spoilers sera enregistrée à l'emplacement suivant : - + Save to a custom path (not recommended) Sauvegarder à un emplacement personnalisé (non recommandé) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Enregistrer la base de données des jetons - + XML; token database (*.xml) XML ; base de données des jetons (*.xml) - + tokens jetons - + Tokens import Importation des jetons - + Please specify a compatible source for token data. Veuillez spécifier une source compatible pour les jetons. - + Download URL: URL de téléchargement: - + Local file: Ficher local: - + Restore default URL Restaurer l'URL par défaut - + Choose file... Choisissez un ficher... - + The token database will be saved at the following location: La base de données des jetons sera enregistrée à l'emplacement suivant : - + Save to a custom path (not recommended) Sauvegarder à un emplacement personnalisé (non recommandé) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Fausse édition contenant les jetons @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Importateur Oracle @@ -292,22 +306,22 @@ OutroPage - + Finished Terminé - + The wizard has finished. L'assistant a terminé. - + You can now start using Cockatrice with the newly updated cards. Vous pouvez maintenant commencer à utiliser Cockatrice avec les cartes mises à jour. - + If the card databases don't reload automatically, restart the Cockatrice client. Si les bases de données de cartes ne rechargent pas automatiquement, redémarrez le client Cockatrice. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Erreur - + + No set has been imported. Aucune édition n'a été importé. - + Sets imported Éditions importées - - A cockatrice database file of %1 MB has been downloaded. - Un fichier de base de données Cockatrice de %1 MB a été téléchargé. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: Les éditions suivantes ont été trouvées : - + Press "Save" to store the imported cards in the Cockatrice database. Cliquez sur « Enregistrer » pour enregistrer les cartes importées dans la base de données de Cockatrice. - + The card database will be saved at the following location: La base de données des cartes sera enregistrée à l'emplacement suivant : - + Save to a custom path (not recommended) Sauvegarder à un emplacement personnalisé (non recommandé) - + &Save Sauvegarder - + + Import finished: %1 cards. Import terminé: %1 cartes. - + + %1: %2 cards imported %1: %2 cartes ajoutées. - + Save card database Sauvegarder la base de carte - + XML; card database (*.xml) XML ; base de données de cartes (*.xml) - + The file could not be saved to %1 Le fichier n'a pu être sauvegarder au chemin '%1' @@ -414,8 +431,9 @@ - Downloading (0MB) - Téléchargement (0 Mo) + Downloading (0 MB) + Downloading (0MB) + @@ -587,7 +605,7 @@ i18n - + English Français (French) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Ne fonctionne qu'en mode spoiler - + Run in no-confirm background mode S'exécuter en mode tâche de fond sans demande de confirmation From 07a699851db93f88afc23c1933cdaeb783f63ecb Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:14:00 +0200 Subject: [PATCH 049/247] Updates for project Cockatrice and language ko (#7557) * Translate oracle_en@source.ts in ko [Manual Sync] 50% of minimum 5% translated source file: 'oracle_en@source.ts' on 'ko'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in ko [Manual Sync] 20% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'ko'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_ko.ts | 7502 ++++++++++++++++------ oracle/translations/oracle_ko.ts | 200 +- 2 files changed, 5652 insertions(+), 2050 deletions(-) diff --git a/cockatrice/translations/cockatrice_ko.ts b/cockatrice/translations/cockatrice_ko.ts index 4b06f2a5e..1a28a9f0b 100644 --- a/cockatrice/translations/cockatrice_ko.ts +++ b/cockatrice/translations/cockatrice_ko.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... 카운터 설정... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 카운터 설정 - + New value for counter '%1': 카운터 '%1'의 값을 지정해 주세요: @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error 오류 - + Could not create themes directory at '%1'. - + Theme settings 테마 설정 - + Current theme: 현재 테마: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering 카드 렌더링 - + Display card names on cards having a picture 이미지가 존재하는 카드에도 카드 이름 표시 - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over 마우스 오버 시 카드 확대 - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 손 레이아웃 - + Display hand horizontally (wastes space) 손의 카드를 가로로 정렬 - + Enable left justification 손의 카드를 좌측으로 정렬 - + Table grid layout 테이블 격자 레이아웃 - + Invert vertical coordinate 전장의 상하배치를 전환 (대지를 앞에 배치) - + Minimum player count for multi-column layout: 다열 레이아웃를 위한 최소 플레이어 인원 (4명 이상 권장) - + Maximum font size for information displayed on cards: 카드 정보에 표시할 폰트의 최대 크기: @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 사용자 계정 추방 - + ban &IP address IP 주소 추방 - + ban client I&D 클라이언트 ID 추방 - + Ban type 추방 유형 - + &permanent ban 영구 추방 - + &temporary ban 기간제 추방 - + &Days: 일: - + &Hours: 시간: - + &Minutes: 분: - + Duration of the ban 추방 기간 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 추방 사유를 적어주세요. 해당 사유는 관리자들만 볼 수 있고 추방 당한 사용자는 볼 수 없습니다. - + Please enter the reason for the ban that will be visible to the banned person. 추방된 사용자가 보게 될 추방 사유를 적어주세요. - + Redact all messages from this user in all rooms - + &OK 확인 - + &Cancel 취소 - + Ban user from server 사용자를 서버에서 추방 - - - - + + + + Error 오류 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 추방의 유형을 선택해 주세요. - + You must have a value in the name ban when selecting the name ban checkbox. 사용자명 추방을 하기 위해선 사용자명을 입력하셔야 합니다. - + You must have a value in the ip ban when selecting the ip ban checkbox. 아이피 추방을 하기 위해선 아이피 주소를 입력하셔야 합니다. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 클라이언트 ID 추방을 하기 위해선 클라이언트 ID를 입력하셔야 합니다. @@ -499,22 +634,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. @@ -522,7 +657,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name 카드 이름 - + Sets 확장판 - + Mana cost 마나 비용 - + Card type 카드 유형 - + P/T 공/방 - + Color(s) 색(들) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -744,22 +1036,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -795,129 +1087,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ This is only saved for moderators and cannot be seen by the banned person. CardSizeWidget - + Card Size @@ -1078,6 +1370,24 @@ This is only saved for moderators and cannot be seen by the banned person. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers - + Success 성공 - + Download URLs have been reset. - + Add URL URL 추가 - - + + URL: URL: - - + + Edit URL URL 수정 - + Add New URL 새 URL 추가 - + Remove URL URL 제거 - + Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - + Reset Download URLs @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number 카드 장수 - + Provider ID - + Card 카드 이름 @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... 덱 불러오기 - + Load remote deck... 원격 덱 불러오기 - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 시작 준비 - + Force start - + Sideboard unlocked 사이드보드 카드 교체 가능 - + Sideboard locked 사이드보드 카드 교체 불가능 - - + + Error 오류 - + The selected file could not be loaded. 지정하신 파일을 불러올 수 없었습니다. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. 다운로드중... - + Known Hosts 기록한 호스트 - + Delete the currently selected saved server - + Refresh the server list with known public servers - + New Host 새로운 서버 - + Name: 호스트명: - + &Host: 호스트 IP: - + &Port: 포트: - + Player &name: 사용자명: - + P&assword: 비밀번호: - + &Save password 비밀번호 저장 - + A&uto connect 자동연결 - + Automatically connect to the most recent login when Cockatrice opens 코카트리스를 실행할 때 최근에 로그인한 서버로 연결합니다. - + If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage - + Reset Password - + Forgot password? - + &Connect - + Server 서버 - + Login 로그인 - + Server Contact - + Connect to Server - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning - + The player name can't be empty. 사용자명은 비어있을 수 없습니다. @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings 설정 기억하기 - + &Description: 게임 이름: - + P&layers: 게임 참가 인원: - + General - + Game type 게임 종류 - + &Password: 비밀번호: - + Only &buddies can join 친구만 입장 가능 - + Only &registered users can join 서버에 가입한 사용자만 입장 가능 - + Joining restrictions 입장 제한 - + &Spectators can watch 관전자 허용 - + Spectators &need a password to watch 관전자 입장 시 비밀번호 필요 - + Spectators can &chat 관전자 대화 가능 - + Spectators can see &hands 관전자에게 플레이어의 손 공개 - + Create game as spectator - + Spectators 관전자 - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear 설정 초기화 - + Create game 게임 만들기 - + Game information 게임 정보 - + Error 오류 - + Server error. 서버 오류. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: 토큰 이름: - + Token 토큰 - + C&olor: 토큰 색: - + white 백색 - + blue 청색 - + black 흑색 - + red 적색 - + green 녹색 - + multicolor 다색 - + colorless 무색 - + &P/T: 공/방 - + &Annotation: 주석: - + &Destroy token when it leaves the table 토큰이 전장을 떠나면 게임에서 제거됨 - + Create face-down (Only hides name) - + Token data 토큰 정보 - + Show &all tokens 모든 토큰 보기 - + Show tokens from this &deck 이 덱에서 사용하는 토큰 보기 - + Choose token from list 목록에서 토큰 선택 - + Create token 토큰 생성 @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. 이미지 없음 @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. 현재 아바타를 삭제하시려면 이미지를 선택하지 않고 확인을 하시면 됩니다. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... 찾아보기 - + Change avatar 아바타 변경 - + Open Image 이미지 불러오기 - + Image Files (*.png *.jpg *.bmp) 이미지 파일 (*.png *.jpg *.bmp) - + Invalid image chosen. 잘못된 이미지를 선택하셨습니다. @@ -2836,38 +3454,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: 예전 비밀번호: - + New password: 새로운 비밀번호: - + Confirm new password: 새로운 비밀번호 확인: - + Change password 비밀번호 변경 - - + + Error 오류 - + Your password is too short. - + The new passwords don't match. 새로운 비밀번호가 일치하지 않습니다. @@ -2875,93 +3493,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: 토큰 이름: - + C&olor: 토큰 색: - + white 백색 - + blue 청색 - + black 흑색 - + red 적색 - + green 녹색 - + multicolor 다색 - + colorless 무색 - + &P/T: 공/방 - + &Annotation: 주석: - + Token data 토큰 정보 - - + + Add token 토큰 추가 - + Remove token 토큰 삭제 - + Edit custom tokens - + Please enter the name of the token: 토큰의 이름을 입력해 주세요. - + Error 오류 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -3039,7 +3657,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started + Hide started games + Hide games that have started @@ -3075,8 +3694,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - 방장 이름: + &Host name: + &Creator name: + @@ -3142,52 +3762,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: 호스트: - + &Port: 포트: - + Player &name: 사용자명: - + Email: 이메일: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. @@ -3195,37 +3815,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: 호스트: - + &Port: 포트: - + Player &name: 사용자명: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. @@ -3233,94 +3853,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: 호스트: - + &Port: 포트: - + Player &name: 사용자명: - + Token: 토큰: - - + + New Password: 새로운 비밀번호: - + Reset Password - + The player name can't be empty. 사용자명은 비어있을 수 없습니다. - - - - + + + + Reset Password Error - + The token can't be empty. - + The new password can't be empty. - + Error 오류 - + Your password is too short. - + The passwords do not match. 비밀번호가 일치하지 않습니다. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3404,165 +4057,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: 호스트 IP: - + &Port: 포트: - + Player &name: 사용자명: - + P&assword: 비밀번호: - + Password (again): 비밀번호 확인: - + Email: 이메일: - + Email (again): 이메일 확인: - + Country: 국가: - + Undefined 미지정 - + Real name: 실명: - + Register to server 서버에 가입 - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning 가입 실패 - + Your password is too short. - + Your passwords do not match, please try again. 입력된 비밀번호가 서로 다릅니다. 다시 시도하여 주십시오. - + Your email addresses do not match, please try again. 입력된 이메일이 서로 다릅니다. 다시 시도하여 주십시오. - + The player name can't be empty. 사용자명은 비어있을 수 없습니다. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3628,46 +4682,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 데이터베이스를 불러올 때 미상의 오류가 발생하였습니다. - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - 카드 데이터베이스가 손상되었습니다. - -잘못된 데이터베이스를 사용 시 코카트리스에 오류가 발생 할 수 있습니다. - -오라클을 다시 실행해서 카드 데이터베이스를 갱신할 수 있습니다. - -데이터베이스 경로를 다시 설정하시겠습니까? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - 카드 데이터베이스가 너무 오래되었습니다. - -카드 정보나 이미지를 불러올때 오류가 발생할 수 있습니다. - -오라클을 다시 실행해서 카드 데이터베이스를 갱신하는 것을 권장합니다. - -데이터베이스 경로를 다시 설정하시겠습니까? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3676,7 +4696,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3685,7 +4705,7 @@ Would you like to change your database location setting? 데이터베이스 경로를 다시 설정하시겠습니까? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3694,7 +4714,7 @@ Would you like to change your database location setting? 데이터베이스 경로를 다시 설정하시겠습니까? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3703,106 +4723,237 @@ Would you like to change your database location setting? - - - + + + Error 오류 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 덱 파일을 보관하는 디렉토리의 경로가 잘못되었습니다. 경로를 다시 설정하시겠습니까? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 카드 이미지 파일을 보관하는 디렉토리의 경로가 잘못되었습니다. 경로를 다시 설정하시겠습니까? - + Settings 환경설정 - + + Search settings... + + + + + OK + + + + General 일반 - + Appearance 외형 - + User Interface 인터페이스 - + Storage - + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat 대화 - + Sound 소리 - + Shortcuts 단축키 + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3810,17 +4961,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next 다음 - + Previous 이전 - + Tip of the Day @@ -3828,165 +4979,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel - + Reinstall 재설치 - + Cancel Download 다운로드 취소 - + Open Download Page 다운로드 페이지 열기 - + Check for Client Updates - - - - + + + + Error 오류 - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. 코카트리스가 SSL 지원 없이 컴파일되어 자동 업데이트를 할 수 없습니다. 공식 홈페이지에서 수동으로 다운 받아주세요. - + Downloading update: %1 - + Checking for updates... 업데이트 확인중... - + Finished checking for updates - + No Update Available - + Cockatrice is up to date! Cockatrice는 최신 버젼입니다! - + You are already running the latest version available in the chosen release channel. - + Current version 현재 버젼 - + Selected release channel - - + + Update Available - - + + A new version of Cockatrice is available! - - + + New version 새로운 버젼 - - + + Released - - + + Changelog 변경점 - + Do you want to update now? 업데이트 하시겠습니까? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error 업데이트 오류 - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... 설치중... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location 다운로드 위치 @@ -3994,17 +5155,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log 디버그 로그 @@ -4242,7 +5403,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4250,7 +5411,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -4258,45 +5419,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4304,140 +5508,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 오류 - + Please join the appropriate room first. 해당 게임이 열려있는 채널에 먼저 들어가시기 바랍니다. - + Wrong password. 잘못된 비밀번호를 입력하셨습니다. - + Spectators are not allowed in this game. 관전이 허용되지 않은 게임입니다. - + The game is already full. 게임 인원이 전부 찼습니다. - + The game does not exist any more. 게임이 더 이상 존재하지 않습니다. - + This game is only open to registered users. 서버에 가입한 사용자만 참가 할 수 있습니다. - + This game is only open to its creator's buddies. 방장의 친구 목록에 등록된 사용자만 참가 할 수 있습니다. - + You are being ignored by the creator of this game. 방장이 당신을 차단하였습니다. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game 게임 참가 - - Password: - 비밀번호: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. 해당 게임이 열려있는 채널에 먼저 들어가시기 바랍니다. - + &Filter games 게임 필터 - + C&lear filter 필터 해제 - + C&reate 게임 생성 - + &Join 게임 참가 - + Join as judge - + J&oin as spectator 관전자로 참가하기 - + Join as judge spectator - + Games shown: %1 / %2 - + Games 게임 목록 @@ -4478,105 +5698,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 일 - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password 비밀번호 필요 - + buddies only 친구만 - + reg. users only 가입한 사용자 전용 - + open decklists - - + + can chat 관전자 대화 가능 - + see hands 손 공개 - + can see hands 관전자에게 손 공개 - + not allowed 허용 안됨 - + Room 게임 - + Age - + Description 게임 이름 - - Creator - 방장 + + Host + - + Type - + Restrictions 입장 제한 - + Players 플레이어 - + Spectators 관전자 @@ -4584,158 +5804,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + 한국어 (Korean) + + + + + + + + + Choose path 경로 선택 - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: 언어: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) - + Paths 디렉토리 경로 - + How to help with translations - + Decks directory: 덱 파일 경로: - + Filters directory: - + Replays directory: 리플레이 파일 경로: - + Pictures directory: 카드 이미지 경로: - + Card database: 카드 데이터베이스 경로: - + Custom database directory: - + Token database: 토큰 데이터베이스 경로: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 서버에서 서포트하는 기능이 클라이언트에 존재하지 않을 경우 알림 - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4743,47 +6080,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4877,59 +6214,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5147,410 +6778,431 @@ You may have to manually download the new version. MainWindow - - + + Player %1 플레이어 %1 - + Load replay 리플레이 불러오기 - + About Cockatrice 코카트리스에 관하여 - + Version - + Cockatrice Webpage 코카트리스 홈페이지 - + Project Manager: 현 프로젝트 매니저: - + Past Project Managers: 전 프로젝트 매니저: - + Developers: 개발진: - + Our Developers 기여자 목록 - + Help Develop! 개발을 도와주세요! - + Translators: 번역진: - + Our Translators 번역진: - + Help Translate! 번역을 도와주세요! - + Support: 기술 지원: - + Report an Issue 문제점 보고 - + Troubleshooting 문제 해결 - + F.A.Q. 자주 묻는 질문 - - + + Error 오류 - + &Connect... 서버로 연결 - + &Disconnect 서버와의 연결 해제 - + Start &local game... 오프라인 게임 시작 - + &Watch replay... 리플레이 재생 - + &Full screen 전체 화면 - + &Register to server... 서버에 가입 - + &Restore password... - + &Settings... 환경설정 - + &Exit 끝내기 - + A&ctions 액션 - + &Cockatrice 코카트리스 - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder 커스텀 이미지 폴더 열기 - + Open custom sets folder 커스텀 셋 폴더 열기 - + Add custom sets/cards 커스텀 셋/카드 추가 - + Reload card database - + Tabs - + &Help 도움말 - + &About Cockatrice 코카트리스에 관하여 - + &Tip of the Day - + Check for Client Updates 클라이언트 업데이트 확인 - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + 확실하십니까? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database 카드 데이터베이스 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes 예 - - + + No 아니오 - + Open settings 설정 열기 - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets 셋 보기 - + Welcome 환영합니다 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information 알림 - + A card database update is already running. 이미 카드 데이터베이스 업데이트가 진행 중입니다. - + Unable to run the card database updater: 카드 데이터베이스 업데이트를 진행할 수 없습니다: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. @@ -5755,615 +7407,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play 전장에 - + from their graveyard - + from exile 추방 영역에 - + from their hand 자신의 손에 - + the top card of %1's library %1의 서고 맨 위의 카드 - + the top card of their library - + from the top of %1's library %1의 서고 맨 위에 - + from the top of their library - + the bottom card of %1's library %1의 서고 맨 밑의 카드 - + the bottom card of their library - + from the bottom of %1's library %1의 서고 맨 밑에 - + from the bottom of their library - + from %1's library %1의 서고에 - + from their library - + from sideboard 사이드보드에 - + from the stack 스택에 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1이(가) %2 맨 위 카드를 공개합니다. - + %1 is not revealing the top card %2 any longer. %1이(가) %2 맨 위 카드를 더 이상 공개하지 않습니다. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. %1님이 자신의 %2에서 자신의 %3을(를) 가리켰습니다. - + %1 points from their %2 to %3's %4. %1님이 자신의 %2에서 %3님의 %4을(를) 가리켰습니다. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. %1님이 토큰 생성: %2%3. - + %1 has loaded a deck (%2). %1이(가) 덱을 불러왔습니다. (해시값: %2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1이(가) %2장의 사이드보드가 있는 덱을 불러왔습니다. (해시값: %3) - + %1 destroys %2. - + a card 카드 한 장 - + %1 gives %2 control over %3. %1이(가) %3의 조종권을 %2에게 넘깁니다. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1이(가) %3서 %2을(를) 전장에 놓았습니다. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1이(가) %3 있던 %2을(를) 추방합니다. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. %1이(가) %3 있던 %2을(를) 서고 맨 밑에 넣었습니다. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1이(가) %3서 %2을(를) 플레이 하였습니다. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. 게임이 종료되었습니다. - + The game has started. 게임이 시작되었습니다. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1님이 게임에 입장했습니다. - + %1 is now watching the game. %1이(가) 관전을 시작하였습니다. - + You have been kicked out of the game. 게임에서 강제 퇴장 당하였습니다. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. %1님이 게임 시작 준비를 끝냈습니다. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. %1님이 %2을(를) 공개. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads 앞면 - + Tails 뒷면 - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1의 차례입니다. - + %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. %1님이 자신의 지속물을 탭. - + %1 untaps their permanents. %1님이 자신의 지속물을 언탭. - + %1 taps %2. %1님이 %2을(를) 탭. - + %1 untaps %2. %1님이 %2을(를) 언탭. - + %1 shuffles %2. %1님이 %2를 셔플했습니다. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1님이 %2을(를) 뗴어냈습니다. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6371,115 +8023,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message 메세지 추가 - - + + Message: 메세지: - + Edit message - + Chat settings 대화 설정 - + Custom alert words 키워드 알림 - + Enable chat mentions 대화 중 본인의 사용자명 멘션 시 해당 문장을 강조 - + Enable mention completer 멘션 자동완성 기능 - + In-game message macros 게임 내 대화 매크로 - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users 서버에 가입하지 않은 사용자의 채널 대화 차단 - + Ignore private messages sent by unregistered users 서버에 가입하지 않은 사용자가 보낸 1:1 대화 차단 - + Ignore private messages sent by non-buddy users - - + + Invert text color 문장 색 반전 - + Enable desktop notifications for private messages 1:1 대화를 받을 시 데스크탑 알림 설정 - + Enable desktop notification for mentions 자신의 사용자명 멘션 시 데스크탑 알림 설정 - + Enable room message history on join 채널 입장 시 이전 대화 기록 표시 - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (16진수 색상 코드) - + Separate words with a space, alphanumeric characters only 각 단어마다 스페이스바로 띄어써주세요. 문자 및 숫자만 가능합니다. @@ -6487,42 +8149,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6601,148 +8263,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6865,30 +8513,21 @@ Cockatrice will now reload the card database. 종료단 - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - ko - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7007,16 +8646,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7037,12 +8932,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7050,25 +8945,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7099,28 +9034,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7136,95 +9071,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services 서비스 - + Hide %1 %1 가리기 - + Hide Others 기타 가리기 - + Show All 모두 보기 - + Preferences... 환경설정... - + Quit %1 %1 종료 - + About %1 %1에 관하여 @@ -7232,17 +9175,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) 코카트리스 카드 데이터베이스 파일 (*.xml) - + All files (*.*) 모든 파일 (*.*) - + Cockatrice replays (*.cor) 코카트리스 리플레이 파일 (*.cor) @@ -7262,17 +9205,22 @@ This setting means you'll only see the default printing for each card, instead o 토큰 - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7283,7 +9231,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7294,100 +9242,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults @@ -7395,121 +9353,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name 덱 이름 - + ID ID - + Upload time 업로드 시간 + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name 게임 이름 - + Players 플레이어 - + Keep 보존 - + Time started 게임 시작 시간 - + Duration (sec) 게임 길이 + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7551,37 +9577,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms 채널 목록 - + Joi&n 채널에 참가 - + Room 게임 - + Description 설명 - + Permissions 권한 - + Players 플레이어 - + Games 게임 @@ -7589,7 +9615,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7597,32 +9623,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7655,56 +9681,92 @@ Fine-tune individual colours in the grid afterwards. 발매 일자 + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7758,12 +9820,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7771,27 +9833,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 음향 효과 켜기 - + Current sounds theme: 현재 소리 테마: - + Test system sound engine 시스템 사운드 엔진 테스트 - + Sound settings 음향 설정 - + Master volume 주 음량 @@ -7799,48 +9861,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7848,157 +9910,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8006,17 +10081,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8137,118 +10228,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8261,42 +10352,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8309,52 +10400,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info 카드 정보 - + Deck 덱 - + Filters 카드 필터 - + &View &보기 - + Card Database - + Printing - + Visible 표시 - + Floating 띄우기 - + Reset layout 화면 레이아웃 초기화 - + Deck: %1 덱 편집기: %1 @@ -8362,49 +10453,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8412,22 +10503,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8435,139 +10526,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 로컬 파일 시스템 - + Server deck storage 서버 덱 보관함 - - + + Open in deck editor 덱 편집기로 열기 - + Rename deck or folder - + Upload deck 덱 업로드 - + Download deck 덱 다운로드 - - - - + + + + New folder 새 폴더 - - + + Delete 삭제 - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name 덱 이름 작성 - + This decklist does not have a name. Please enter a name: 이 덱리스트는 이름이 없습니다. 이름을 입력해 주세요: - + Unnamed deck 이름 없는 덱 - + Failed to upload deck to server - + Delete local file 로컬 파일 삭제 - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: 새 폴더의 이름: - + Deck Storage @@ -8575,20 +10764,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8601,27 +11041,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8634,191 +11074,207 @@ Please enter a name: TabGame - - - + + + Replay 리플레이 - - + + Game 게임 - - + + Player List 플레이어 목록 - - + + + Invite + + + + + Card Info 카드 정보 - - + + Messages 메세지 - - + + Replay Timeline 리플레이 타임라인 - + &Phases 단계 - + &Game 게임 - + Next &phase 다음 단계로 진행 - + Next phase with &action - + Next &turn 턴 넘기기 - + Reverse turn order - + &Remove all local arrows 내가 그린 화살표 제거 - + Rotate View Cl&ockwise 플레이어 위치 시계방향으로 조정 - + Rotate View Co&unterclockwise 플레이어 위치 반시계방향으로 조정 - + Game &information 게임 정보 - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede 항복 - + &Leave game 게임 나가기 - + C&lose replay 리플레이 닫기 - + &Focus Chat - + &Say: 말하기: - + Selected cards - + &View &보기 - + Visible 표시 - + Floating 띄우기 - + Reset layout 레이아웃 초기화 - + Concede 항복 - + Are you sure you want to concede this game? 정말 게임에서 항복하시겠습니까? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game 게임 나가기 - + Are you sure you want to leave this game? 정말 게임에서 나가시겠습니까? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. 게임에서 강제 퇴장 당하였습니다. @@ -8834,158 +11290,158 @@ Please enter a name: TabLog - + Logs 기록 - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs 게임 기록 - + Chat Logs 대화 기록 - - + + Error 오류 - + You must select at least one filter. 최소한 한 개 이상의 필터를 선택해주세요. - + You have to select a valid number of days to locate. - + Username: 사용자명: - + IP Address: IP 주소: - + Game Name: 게임 이름: - + GameID: 게임 ID: - + Message: 메세지: - + Main Room 대화 채널 - + Game Room 게임 방 - + Private Chat 1:1 대화 - + Past X Days: ~일 전: - + Today 오늘 - + Last Hour 1시간 이내 - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. 최소한 한개 이상의 필터를 지정해주세요. 자세히 지정하시면 좀 더 세분화된 결과를 보실 수 있습니다. - + Get User Logs - + Clear Filters - + Filters 필터 - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8993,218 +11449,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat 1:1 대화 - + &Leave 나가기 - + %1 - Private chat %1 - 1:1 대화 - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1이(가) 서버와의 연결을 끊었습니다. - + %1 has joined the server. %1이(가) 서버에 연결하였습니다. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system 로컬 파일 시스템 - + Server replay storage 서버 리플레이 보관함 - - + + Watch replay 리플레이 보기 - + Rename - - + + New folder - - + + Delete 삭제 - + Open replays folder - + Download replay 리플레이 다운로드 - + Toggle expiration lock 자동 삭제 여부 토글 - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file 로컬 파일 삭제 - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 서버에 저장된 리플레이 삭제 @@ -9214,65 +12010,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: 말하기: - + Chat 대화 - + &Room 채널 - + &Leave room 채널에서 나가기 - + &Clear chat 대화 내용 삭제 - + Chat Settings... 대화 설정... - + mentioned you. 이(가) 당신에게 멘션을 보냈습니다. - + Click to view 여기를 눌러 확인 - + You are flooding the chat. Please wait a couple of seconds. 너무 빨리 말하고 있습니다. 조금만 기다려 주세요. @@ -9280,35 +12489,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server 서버 - - - - + + + + Error 오류 - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9316,98 +12525,174 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? 확실하십니까? - + There are still open games. Are you sure you want to quit? 참가 중인 게임이 있습니다. 정말로 코카트리스를 종료하시겠습니까? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9415,32 +12700,32 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion 승급 - + You have been promoted. Please log out and back in for changes to take effect. - + Warned 경고 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 운영진으로부터 %1의 사유로 경고를 받았습니다. @@ -9448,7 +12733,7 @@ Please refrain from engaging in this activity or further actions may be taken ag 문의사항은 관리자에게 1:1대화로 물어보시기 바랍니다. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -9467,6 +12752,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9481,6 +12794,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9550,57 +12922,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9608,216 +12975,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details 사용자 정보 - + Private &chat 1:1 대화 - + Show this user's &games 이 사용자의 게임 보기 - - - Add to &buddy list - 친구 목록에 추가 - - - - Remove from &buddy list - 친구 목록에서 삭제 - - - - Add to &ignore list - 차단 목록에 추가 - - - - Remove from &ignore list - 차단 목록에서 제거 - - - - Kick from &game - 게임에서 강제 퇴장 - - - - Warn user - - - View user's war&n history + View this user's &public decks - Ban from &server - 서버에서 추방 + Add to &buddy list + 친구 목록에 추가 - View user's &ban history - + Remove from &buddy list + 친구 목록에서 삭제 - &Promote user to moderator - 사용자를 관리자로 승급 + Add to &ignore list + 차단 목록에 추가 - Dem&ote user from moderator - 사용자를 관리자로부터 강등 + Remove from &ignore list + 차단 목록에서 제거 - Promote user to &judge - + Kick from &game + 게임에서 강제 퇴장 - Demote user from judge + Report user + Warn user + + + + + View user's war&n history + + + + + Ban from &server + 서버에서 추방 + + + + View user's &ban history + + + + + &Promote user to moderator + 사용자를 관리자로 승급 + + + + Dem&ote user from moderator + 사용자를 관리자로부터 강등 + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games %1의 게임 - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. 경고를 받은 적이 없는 사용자입니다. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success 성공 - + Successfully promoted user. 해당 사용자 승급에 성공하였습니다. - + Successfully demoted user. 해당 사용자 강등에 성공하였습니다. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed 실패 - + Failed to promote user. 해당 사용자 승급에 실패하였습니다. - + Failed to demote user. 해당 사용자 강등에 실패하였습니다. - + Copy hash to clipboard - + Remove this user's messages @@ -9855,61 +13272,61 @@ Please refrain from engaging in this activity or further actions may be taken ag 운영자 - + Moderator 관리자 - + Registered user 서버에 가입한 사용자 - - + + Unregistered user 서버에 가입하지 않은 사용자 - + Judge - + Unknown 미상 - + The entered password does not match your account. - - - - + + + + Information 사용자 정보 - + User information updated. 사용자 정보가 갱신되었습니다. - - - - - - + + + + + - - - + + + + Error 오류 @@ -9933,283 +13350,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. 본 서버는 당신의 사용자 정보를 수정 할 수 없게 설정되어 있습니다. - + Password changed. 비밀번호가 변경되었습니다. - + This server does not permit you to change your password. 본 서버는 당신의 비밀번호를 수정 할 수 없게 설정되어 있습니다. - + The new password is too short. 새로운 비밀번호가 너무 짧습니다. - + The old password is incorrect. 예전 비밀번호가 잘못되었습니다. - + Avatar updated. 아바타를 변경하였습니다. - + This server does not permit you to update your avatar. 본 서버는 당신의 아바타를 변경 할 수 없게 설정되어 있습니다. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - 본 서버는 당신의 사용자 정보를 수정 할 수 없게 설정되어 있습니다. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10217,223 +13643,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings 일반 인터페이스 설정 - + &Double-click cards to play them (instead of single-click) 카드를 더블 클릭해서 발동 (해제시 한번만 클릭하면 발동 됨) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default 모든 대지가 아닌 카드를 발동 시에 스택으로 이동 (해제시 전장으로 바로 이동) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 토큰에 카드 텍스트 주석 자동 추가 - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings - + Enable notifications in taskbar 상태 표시줄 알림 설정 - + Notify in the taskbar for game events while you are spectating 관전중인 게임의 상태 표시줄 알림 설정 - + Notify in the taskbar when users in your buddy list connect - + Animation settings 애니메이션 설정 - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation 탭/언탭 애니메이션 - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10470,22 +14017,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10493,72 +14040,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10660,7 +14207,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10668,19 +14215,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10726,28 +14273,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10755,43 +14302,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10807,12 +14354,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10820,17 +14367,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10838,7 +14385,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10846,52 +14393,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10899,7 +14451,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10907,22 +14459,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10930,17 +14482,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10948,177 +14505,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 어떤 종류의 경고를 보내시겠습니까? - + Redact all messages from this user in all rooms - + &OK 확인 - + &Cancel 취소 - + Warn user for misconduct 사용자의 부적절한 행동에 경고 - - + + Error 오류 - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top 선택한 확장판을 맨 위로 올리기 - + Move selected set up 선택한 확장판을 한 단계 올리기 - + Move selected set down 선택한 확장판을 한 단계 내리기 - + Move selected set to the bottom 선택한 확장판을 맨 아래로 내리기 - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets 모든 확장판 활성화 - + Disable all sets 모든 확장판 비활성화 - + Enable selected set(s) 선택한 세트 적용 - + Disable selected set(s) 선택한 세트 미적용 - + Deck Editor 덱 편집기 - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 카드 이미지 - - How to use custom card art + + Search by set name, code, type, or release date + How to use custom card art + + + + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -11126,93 +14708,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing 닫은 후 덱 섞기 - + pile view 카드 유형별 행 정렬 - - i18n - - - English - 한국어 (Korean) - - main - + Connect on startup - + Debug to file @@ -11996,6 +15570,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard 무덤 + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_ko.ts b/oracle/translations/oracle_ko.ts index 3b0b0187c..e8dd4defb 100644 --- a/oracle/translations/oracle_ko.ts +++ b/oracle/translations/oracle_ko.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction 개요 - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. - + Interface language: - + Version: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection 확장판 목록 파일 주소 입력 - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: 다운로드 주소: - + Local file: 파일 위치: - + Restore default URL 기본 주소로 복원 - + Choose file... 파일 선택... - + Load sets file 확장판 목록 파일 불러오기 - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error 오류 - + The provided URL is not valid. 잘못된 주소를 입력하셨습니다. - - Downloading (0MB) - 다운로드 중 (0MB) - - - + Please choose a file. 확장판 목록 파일을 선택해 주세요. - + Cannot open file '%1'. 파일 '%1'을(를) 열 수 없습니다. - - Downloading (%1MB) - 다운로드 중 (%1MB) - - - + Network error: %1. 네트워크 오류 : %1. - + Parsing file 목록 파싱중 - + Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. 압축파일 열기 실패 : %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. 압축 풀기 실패 : 압축 파일에 확장판 목록 파일 이외의 파일이 있습니다. - + Zip extraction failed: %1. 압축 풀기 실패 : %1. - + Sorry, this version of Oracle does not support zipped files. 죄송합니다. 본 버전에서는 압축 파일을 지원하지 않습니다. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. 파일을 성공적으로 다운로드 하였으나 확장판 정보가 들어있지 않습니다. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database - + XML; spoiler database (*.xml) - + spoiler - + Spoilers import - + Please specify a compatible source for spoiler data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + Tokens import - + Please specify a compatible source for token data. - + Download URL: 웹 주소: - + Local file: - + Restore default URL 기본 주소로 복원 - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens 토큰 정보가 들어있는 더미 확장판 @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer 오라클 @@ -292,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error 오류 - + + No set has been imported. 아무 확장판도 불러오지 못했습니다. - + Sets imported 확장판 불러오기 완료 - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. 총 %1장의 카드 불러오기 완료 - + + %1: %2 cards imported %1에서 %2장의 카드 불러옴 - + Save card database 카드 데이터베이스 저장 - + XML; card database (*.xml) 카드 데이터베이스 XML 파일 (*.xml) - + The file could not be saved to %1 파일을 %1에 저장 할 수 없습니다. @@ -414,7 +431,8 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) @@ -587,7 +605,7 @@ i18n - + English 한국어 (Korean) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode From b231f55df4fdfa7844b365d7bbb2b7f689f42594 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:15:29 +0200 Subject: [PATCH 050/247] Updates for project Cockatrice and language nl (#7559) * Translate oracle_en@source.ts in nl [Manual Sync] 85% of minimum 5% translated source file: 'oracle_en@source.ts' on 'nl'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in nl [Manual Sync] 61% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'nl'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_nl.ts | 7454 ++++++++++++++++------ oracle/translations/oracle_nl.ts | 204 +- 2 files changed, 5630 insertions(+), 2028 deletions(-) diff --git a/cockatrice/translations/cockatrice_nl.ts b/cockatrice/translations/cockatrice_nl.ts index 882e50dae..8c89946e1 100644 --- a/cockatrice/translations/cockatrice_nl.ts +++ b/cockatrice/translations/cockatrice_nl.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Teller instellen... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Teller instellen - + New value for counter '%1': Nieuwe waarde voor teller '%1': @@ -36,66 +36,115 @@ AbstractTabDeckEditor - + Open in new tab Open in nieuw tabblad - + Are you sure? Weet je het zeker? - + The decklist has been modified. Do you want to save the changes? De decklijst is gewijzigd. Wil je de wijzigingen opslaan? - - - - - - + + + + + + Error Foutmelding - + Could not open deck at %1 Kon het deckbestand niet openen: %1 - + Could not save remote deck Kon remote deck niet opslaan - - + + The deck could not be saved. Please check that the directory is writable and try again. Het deck kon niet opgeslagen worden. Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. - + Save deck Deck opslaan - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. Het deck kon niet opslagen worden. - + There are no cards in your deck to be exported Er zijn geen kaarten in je deck om te exporteren. + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. AdminNotesDialog - + Update Notes Update Notities - + Admin Notes for %1 Admin Notities voor %1 @@ -138,218 +187,304 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. AppearanceSettingsPage - + seconds secondes - + Error Foutmelding - + Could not create themes directory at '%1'. Kon de themes map niet aanmaken op '%1'. - + Theme settings Thema opties - + Current theme: Huidige thema: - + Open themes folder Themasmap Openen - + Home tab background source: Starttabblad achtergrond bron: - + Home tab background shuffle frequency: Starttabblad achtergrond verversperiode. - + Disabled Uitgeschakeld - + Menu settings Menu instellingen - + Show keyboard shortcuts in right-click menus Laat snelkoppelingen in rechtermuisknop menus zien - + Show game filter toolbar above list in room tab Spelfilter werkbalk weergeven boven lijst in kamertab - + Card rendering Kaartweergave - + Display card names on cards having a picture Kaartnamen altijd weergeven op kaarten met een afbeelding - + Auto-Rotate cards with sideways layout Zijwaardse kaarten gedraaid weergeven - + Override all card art with personal set preference (Pre-ProviderID change behavior) Overschrijf alle kaart beelden met eigen set voorkeuren (Gedrag van voor implementatie van ProviderID) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector Plaats sets van andere kaarten in het deck bovenin de drukselector - + Scale cards on mouse over Kaarten schalen met de muis - + Use rounded card corners Ronde kaarthoekjes - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand Minimum overlap percentage van kaarten op de stack en in verticale handweergave - + Maximum initial height for card view window: Grootste begingrootte voor kaartbekijkvenster - - + + rows rijen - + Maximum expanded height for card view window: Grootste uitgebreide grootte voor kaartbekijkvenster - + Card counters Kaart-counters - + Counter %1 Counter %1 - + Hand layout Handweergave - + Display hand horizontally (wastes space) Hand horizontaal weergeven (gebruikt extra ruimte) - + Enable left justification Linker justificatie inschakelen - + Table grid layout Tafelindeling - + Invert vertical coordinate Verticale coördinaat omkeren - + Minimum player count for multi-column layout: Minimaal aantal spelers voor kolommenindeling: - + Maximum font size for information displayed on cards: Maximale lettergrootte voor informatie die op kaarten wordt weergegeven: @@ -357,12 +492,12 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. ArchidektApiResponseDeckDisplayWidget - + Back to results Terug naar resultaten - + Open Deck in Deck Editor Open Deck in Deckbewerker @@ -388,112 +523,112 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. BanDialog - + ban &user name ban &gebruikersnaam - + ban &IP address ban &IP-adres - + ban client I&D ban gebruiker I&D - + Ban type Ban type - + &permanent ban &definitieve ban - + &temporary ban &tijdelijke ban - + &Days: &Dagen: - + &Hours: &Uur: - + &Minutes: &Minuten: - + Duration of the ban Duur van de ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Voer een reden voor de ban in. Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de verbannen persoon. - + Please enter the reason for the ban that will be visible to the banned person. Voer een reden in die zichtbaar zal zijn voor de verbannen persoon. - + Redact all messages from this user in all rooms Verwijder alle berichten van deze gebruiker in alle kamers - + &OK &OK - + &Cancel &Annuleren - + Ban user from server Ban gebruiker van server - - - - + + + + Error Fout - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. U moet een ban gebaseerd op IP, naam, gebruikers ID of een combinatie van de drie. - + You must have a value in the name ban when selecting the name ban checkbox. U moet een naam meegeven als u een naam gebaseerde ban wilt uitvoeren. - + You must have a value in the ip ban when selecting the ip ban checkbox. U moet een IP meegeven als u een IP gebaseerde ban wilt uitvoeren. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. U moet een gebruikers ID meegeven als u een gebruikers ID gebaseerde ban wilt uitvoeren. @@ -501,22 +636,22 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Geen antwoord ontvangen van de release update server. - + Invalid reply received from the release update server. Ongeldig antwoord ontvangen van de release update server. - + No reply received from the file update server. Geen antwoord ontvangen van de bestandsupdateserver. @@ -524,7 +659,20 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -532,22 +680,22 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -555,65 +703,209 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardDatabaseModel - + Name Naam - + Sets Sets - + Mana cost Manakosten - + Card type Kaarttype - + P/T P/T - + Color(s) Kleur(en) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -723,22 +1015,22 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardInfoFrameWidget - + Image Plaatje - + Description Beschrijving - + Both Beide - + View transformation Bekijk gedaanteverandering @@ -746,22 +1038,22 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardInfoPictureWidget - + View related cards Bekijk gerelateerde kaarten - + Add card to deck Voeg kaart aan deck toe - + Mainboard Mainboard - + Sideboard Sideboard @@ -769,27 +1061,27 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardInfoTextWidget - + Name: Naam: - + Set: Set: - + Collector Number: Verzamelnummer: - + Related cards: Gerelateerde kaarten: - + Unknown card: Onbekende kaart: @@ -797,129 +1089,129 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardMenu - + Re&veal to... &Onthul aan.... - + &All players &Alle spelers - + View related cards Bekijk gerelateerde kaarten - + Token: Token: - + All tokens Alle tokens - + &Select All &Selecteer Alle - + S&elect Row S&electeer Rij - + S&elect Column S&electeer Kolom - + &Play &Speel - + &Hide &Verberg - + Play &Face Down Speel kaart &verdekt - + &Tap / Untap Turn sideways or back again &Tap / Ontap - + Skip &untapping &Ontappen overslaan - + T&urn Over Turn face up/face down &Draai om - + &Peek at card face &Gluren naar de voorkant van kaart - + &Clone &Kloneer - + Attac&h to card... Aan de kaart &bevestigen.... - + Unattac&h &Losmaken - + &Draw arrow... &Teken pijl.... - + &Set annotation... Aantekeningen &zetten.... - + Reduce life by power - + Ca&rd counters Kaart-&counters - + &Add counter (%1) Voeg counter &toe (%1) - + &Remove counter (%1) &Verwijder counter (%1) - + &Set counters (%1)... Counters &instellen (%1)... @@ -940,7 +1232,7 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardSizeWidget - + Card Size Kaartgrootte @@ -1080,6 +1372,24 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v %1s aangepaste zone '%2' + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1109,280 +1419,382 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1390,113 +1802,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1546,7 +1958,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database Kaartdatabase @@ -1562,17 +1974,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Zoek op kaartnaam (of zoekexpressies) - + Add card to &maindeck Kaart aan deck &toevoegen - + Add card to &sideboard Kaart aan &sideboard toevoegen @@ -1580,97 +1992,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... Database Laden... - + Banner Card Vaandelkaart - + Main Type Hoofdtype - + Mana Cost Mana Kosten - + Colors Kleuren - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing Selecteer Druk - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Deck - + Deck &name: Deck&naam: - + Banner Card/Tags Visibility Settings Vaandelkaart/Tags zichtbaarheidsinstellingen - + Show banner card selection menu Selectiemenu van vaandelkaarten weergeven - + Show tags selection menu Selectiemenu van tags weergeven - + &Comments: &Opmerkingen: - + Group by: Groepeer op: - + Format: Format: - + Hash: Hash: - + &Increment number Ver&hoog getal - + &Decrement number Ver&laag getal - + &Remove row Ver&wijder rij - + Swap card to/from sideboard Wissel kaart van/naar sideboard @@ -1678,17 +2151,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Filters - + &Clear all filters &Wis alle filters - + Delete selected Selectie verwijderen @@ -1696,114 +2169,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor &Deckbewerker - + &New deck &Nieuw deck - + &Load deck... &Laad deck... - + Load recent deck... Laad recent deck... - + Clear Wis - + &Save deck &Bewaar deck - + Save deck &as... Bewaar deck &als... - + + Share deck... + + + + Load deck from cl&ipboard... Laad deck van &klembord.... - + Edit deck in clipboard Deck in klembord aanpassen... - - + + Annotated Geannoteerd - - + + Not Annotated Niet Geannoteerd - + Save deck to clipboard Deck opslaan op klembord - + Annotated (No set info) Geannoteerd (Geen set info) - + Not Annotated (No set info) Niet Geannoteerd (Geen set info) - + &Print deck... &Print deck... - + Load deck from online service... Deck van online service laden... - + &Send deck to online service Deck naar online service &sturen - + Create decklist (decklist.org) Maak decklist aan (decklist.org) - + Create decklist (decklist.xyz) Maak decklist aan (decklist.xyz) - + Analyze deck (deckstats.net) Analyseer deck (deckstats.net) - + Analyze deck (tappedout.net) Analyseer deck (tappedout.net) - + &Close &Sluiten @@ -1811,7 +2289,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector Drukselector @@ -1819,110 +2297,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Update Spoilers - + Success Succes - + Download URLs have been reset. Download URL's zijn gereset. - + Add URL URL toevoegen - - + + URL: URL: - - + + Edit URL URL bewerken - + Add New URL Nieuwe URL toevoegen - + Remove URL URL verwijderen - + Updating... Bijwerken... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Kies locatie - + URL Download Priority URL Download Prioriteit - + Spoilers Spoilers - + Download Spoilers Automatically Spoilers Automatisch Downloaden - + Spoiler Location: Spoiler Locatie: - + Last Change Laatste Wijziging - + Spoilers download automatically on launch Spoilers downloaden automatisch bij opstarten - + Press the button to manually update without relaunching Druk op de knop om handmatig bij te werken zonder opnieuw te starten. - + Do not close settings until manual update is complete Sluit de instellingen niet af voordat de handmatige update is voltooid - + Download card pictures on the fly Kaartafbeeldingen on the fly downloaden - + How to add a custom URL Hoe een aangepaste URL toe te voegen - + Reset Download URLs Reset Download URL's @@ -1930,32 +2450,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo Ontdoen - + Redo Herdoen - + Undo/Redo history Ontdoe/Herdoe geschiedenis - + Click on an entry to revert to that point in the history. Klik een regel om terug te gaan naar dat punt in de geschiedenis. - + [redo] [herdoen] - + [undo] [ontdoen] @@ -1963,27 +2483,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Hoeveelheid - + Set Set - + Number Nummer - + Provider ID Provider ID - + Card Kaart @@ -2004,17 +2524,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Modus: Exacte Overeenkomst - + Mode: Includes Modus: Bevat - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) Kleur-identiteit filtermodus (AND/OR/NOT samenvoegingen van filters) @@ -2022,7 +2547,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Bewerk tags ... @@ -2030,62 +2555,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Deck Tags Beheren - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: Beheer je deck-tags. Vink tags aan of uit naar behoefte, of voeg nieuwe toe: - + Add a new tag (e.g., Aggro️) Nieuwe tag toevoegen (e.g., Aggro) - + Add Tag Tag Toevoegen - + Filter tags... Tags filteren... - + OK OK - + Edit default tags Standaardtags bewerken - + Cancel Annuleren - + Invalid Input Ongeldige Invoer - + Tag name cannot be empty! Naam van tag mog niet leeg zijn! - + Duplicate Tag Tag Dupliceren - + This tag already exists. Deze tag bestaat al. @@ -2093,94 +2618,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card Vaandelkaart - + Open in deck editor In deckbewerker openen - + + Share deck... + + + + Edit Tags Tags Aanpassen - + Rename Deck Deck Hernoemen - + Save Deck to Clipboard Bewaar Deck op Klembord - + Annotated Geannoteerd - + Annotated (No set info) Geannoteerd (Geen set info) - + Not Annotated Niet Geannoteerd - + Not Annotated (No set info) Niet Geannoteerd (Geen set info) - + Rename File Bestand Hernoemen - + Delete File Bestand Verwijderen - + Set Banner Card Vaandelkaart Instellen - - + + New name: Nieuwe naam: - - + + Rename deck + + + + + Rename file + + + + + Error Foutmelding - + Rename failed Hernoemen mislukt - + Delete file Bestand Verwijderen - + Are you sure you want to delete the selected file? Weet je zeker dat je het opgeslagen bestand wilt verwijderen? - + Delete failed Verwijderen mislukt @@ -2188,57 +2728,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" Hernoem deck naar "%1" van "%2" - + Updated comments (was %1 chars, now %2 chars) Opmerkingen bijgewerkt (van %1 karakters naar %2 karakters) - + Set banner card to %1 (%2) Vaandelkaart veranderd naar %1 (%2) - + + Set playmat to %1 + + + + Tags changed Tags veranderd - + Set format to %1 Format veranderd naar %1 - + Added (%1): %2 (%3) %4 Toegevoegd (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Verplaatst naar %1 1 × "%2" (%3) - + Removed "%1" (all copies) Verwijderd "%1" (alle exemplaren) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Toegevoegd - + Removed Verwijderd @@ -2260,88 +2845,116 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Deck laden... - + Load remote deck... Laad extern deck... - + Load from clipboard... Van klembord laden.... - + Load from website... Van website laden... - + Unload deck Deck uitladen - + Ready to start Gereed voor begin - + Force start Geforceerd starten - + Sideboard unlocked Sideboard ontgrendeld - + Sideboard locked Sideboard vergrendeld - - + + Error Fout - + The selected file could not be loaded. Het geselecteerde bestand kon niet geladen worden. - + Deck is greater than maximum file size. Deck is groter dan maximum bestandsgrootte. - + Are you sure you want to force start? This will kick all non-ready players from the game. Weet je zeker dat je geforceerd beginnen wil? Dit zal alle spelers die niet gereed zijn van het spel verwijderen. - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion Deckformaat Omzetten - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? @@ -2350,7 +2963,7 @@ Tags kunnen alleen worden toegevoegd aan decks in .cod-formaat. Wil je het deck omzetten naar het .cod-formaat? - + Remember and automatically apply choice in the future Onthouden en automatisch keuze toepassen in de toekomst @@ -2363,143 +2976,143 @@ Wil je het deck omzetten naar het .cod-formaat? Downloaden... - + Known Hosts Bekende Hosts - + Delete the currently selected saved server Verwijder de geselecteerde opgeslagen server - + Refresh the server list with known public servers Hernieuw de serverlijst met bekende publieke servers - + New Host Nieuwe Eigenaar - + Name: Naam: - + &Host: &Gastheer - + &Port: &Poort - + Player &name: Spelers&naam: - + P&assword: W&achtwoord: - + &Save password Wachtwoord &Opslaan - + A&uto connect A&utomatisch verbinden - + Automatically connect to the most recent login when Cockatrice opens Maak automatisch verbinding met de meest recente login wanneer Cockatrice wordt geopend. - + If you have any trouble connecting or registering then contact the server staff for help! Als u problemen ondervindt bij het aansluiten of registreren, neem dan contact op met het serverteam voor hulp! - - + + Webpage Website - + Reset Password Wachtwoord veranderen - + Forgot password? Wachtwoord vergeten? - + &Connect &Verbinden - + Server Server - + Login Login - + Server Contact Servercontact - + Connect to Server Verbinding maken met server - + Server URL Server URL - + Communication Port Communicatiepoort - + Unique Server Name Unieke Servernaam - + Connection Warning Verbindingswaarschuwing - + You need to name your new connection profile. U moet het nieuwe verbindingsprofiel een naam geven. - + Connect Warning Verbindingswaarschuwing - + The player name can't be empty. De naam van de speler mag niet leeg zijn. @@ -2507,122 +3120,122 @@ Wil je het deck omzetten naar het .cod-formaat? DlgCreateGame - + Re&member settings Instellingen &Onthouden - + &Description: &Omschrijving: - + P&layers: Spe&lers: - + General Algemeen - + Game type Spel type - + &Password: &Wachtwoord: - + Only &buddies can join Alleen &maatjes kunnen aansluiten - + Only &registered users can join Alleen ge&registreerde gebruikers kunnen aansluiten - + Joining restrictions Restricties voor aansluiten - + &Spectators can watch Toe&schouwers kunnen meekijken - + Spectators &need a password to watch Toeschouwers hebben een wachtwoord &nodig om mee te kijken - + Spectators can &chat Toeschouwers kunnen &chatten - + Spectators can see &hands Toeschouwers kunnen &handen zien - + Create game as spectator Maak spel aan als toeschouwer - + Spectators Toeschouwers - + Starting life total: Beginnend levenstotaal: - + Open decklists in lobby Open decklijsten in lobby - + Create game as judge Maak spel aan als judge - + Game setup options Spel opzet opties - + &Clear &Opschonen - + Create game Spel aanmaken - + Game information Spelgegevens - + Error Fout - + Server error. Server Fout @@ -2630,97 +3243,97 @@ Wil je het deck omzetten naar het .cod-formaat? DlgCreateToken - + &Name: &Naam: - + Token Token - + C&olor: Kle&ur: - + white wit - + blue blauw - + black zwart - + red rood - + green groen - + multicolor veelkleurig - + colorless kleurloos - + &P/T: &P/T: - + &Annotation: &Annotatie: - + &Destroy token when it leaves the table &Vernietig token als het de tafel verlaat - + Create face-down (Only hides name) Verdekt aanmaken (Verbergt alleen naam) - + Token data Token data - + Show &all tokens Laat &alle tokens zien - + Show tokens from this &deck Laat tokens van dit &dek zien - + Choose token from list Kies token uit lijst - + Create token Maak token @@ -2728,53 +3341,53 @@ Wil je het deck omzetten naar het .cod-formaat? DlgDefaultTagsEditor - + Edit Tags Tags Aanpassen - + Add Toevoegen - + Confirm Bevestig - + Cancel Annuleren - + Enter a tag and press Enter Vul tag in en druk Enter - - + + ✖ ✖ - + Invalid Input Ongeldige Invoer - + Tag name cannot be empty! Naam van tag mog niet leeg zijn! - + Duplicate Tag Tag Dupliceren - + This tag already exists. Deze tag bestaat al. @@ -2783,7 +3396,7 @@ Wil je het deck omzetten naar het .cod-formaat? DlgEditAvatar - + No image chosen. Geen afbeelding gekozen. @@ -2795,27 +3408,32 @@ To remove your current avatar, confirm without choosing a new image. Om je avatar te verwijderen, accepteer zonder een nieuwe afbeelding te kiezen. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Zoeken... - + Change avatar Verander Icoon - + Open Image Afbeelding openen - + Image Files (*.png *.jpg *.bmp) Afbeeldingen (*.png *.jpg *.bmp) - + Invalid image chosen. Geen geldige afbeelding gekozen @@ -2841,38 +3459,38 @@ Om je avatar te verwijderen, accepteer zonder een nieuwe afbeelding te kiezen. DlgEditPassword - + Old password: Oud wachtwoord: - + New password: Nieuw wachtwoord: - + Confirm new password: Herhaal nieuw wachtwoord: - + Change password Verander wachtwoord - - + + Error Fout - + Your password is too short. Je wachtwoord is te kort. - + The new passwords don't match. Nieuwe wachtwoorden komen niet overeen. @@ -2880,93 +3498,93 @@ Om je avatar te verwijderen, accepteer zonder een nieuwe afbeelding te kiezen. DlgEditTokens - + &Name: &Naam: - + C&olor: Kle&ur: - + white wit - + blue blauw - + black zwart - + red rood - + green groen - + multicolor veelkleurig - + colorless kleurloos - + &P/T: &P/T: - + &Annotation: &Annotatie: - + Token data Token data - - + + Add token Voeg token toe - + Remove token Verwijder token - + Edit custom tokens Aangepaste tokens bewerken - + Please enter the name of the token: Gelieve de naam van de token in te voeren: - + Error Fout - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. De gekozen naam is al in gebruik door een bestaande kaart of token. @@ -3045,8 +3663,9 @@ Zorg ervoor dat u de 'Token' set in het " Beheer sets" dialo - Hide games that have started - Verberg spellen die al begonnen zijn + Hide started games + Hide games that have started + @@ -3081,8 +3700,9 @@ Zorg ervoor dat u de 'Token' set in het " Beheer sets" dialo - &Creator name: - Naam van &maker: + &Host name: + &Creator name: + @@ -3148,52 +3768,52 @@ Zorg ervoor dat u de 'Token' set in het " Beheer sets" dialo DlgForgotPasswordChallenge - + Reset Password Challenge Warning Wachtwoord Vergeten Challenge Waarschuwing - + A problem has occurred. Please try to request a new password again. Een probleem heeft zich opgetreden. Gelieve opnieuw een wachtwoord aan te vragen. - + Enter the information of the server and the account you'd like to request a new password for. Vul de gegevens in van de server en het account waar je een nieuw wachtwoord aan voor wil vragen. - + &Host: &Host: - + &Port: &Poort: - + Player &name: &Spelernaam: - + Email: E-mail: - + Reset Password Challenge Wachtwoord Vergeten Challenge - + Reset Password Challenge Error Foutmelding Wachtwoord Vergeten Challenge - + The email address can't be empty. Het e-mailadres mag niet leeg zijn. @@ -3201,37 +3821,37 @@ Zorg ervoor dat u de 'Token' set in het " Beheer sets" dialo DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Vul de gegevens in van de server waar je een nieuw wachtwoord aan voor wil vragen. - + &Host: &Host: - + &Port: &Poort: - + Player &name: &Spelernaam: - + Reset Password Request Wachtwoord Vergeten Aanvraag - + Reset Password Error Foutmelding Wachtwoord Resetten - + The player name can't be empty. De spelernaam kan niet leeg zijn. @@ -3239,94 +3859,127 @@ Zorg ervoor dat u de 'Token' set in het " Beheer sets" dialo DlgForgotPasswordReset - + Reset Password Warning Waarschuwing Wachtwoord Resetten - + A problem has occurred. Please try to request a new password again. Een probleem heeft zich opgetreden. Gelieve opnieuw een wachtwoord aan te vragen. - + Enter the received token and the new password in order to set your new password. Vul het ontvangen token en het nieuwe wachtwoord in om je wachtwoord in te stellen. - + &Host: &Host: - + &Port: &Poort: - + Player &name: &Spelernaam: - + Token: Token: - - + + New Password: Nieuw Wachtwoord: - + Reset Password Reset Wachtwoord - + The player name can't be empty. De spelernaam kan niet leeg zijn. - - - - + + + + Reset Password Error Foutmelding Resetten Wachtwoord - + The token can't be empty. De token mag niet leeg zijn. - + The new password can't be empty. Het nieuwe wachtwoord mag niet leeg zijn. - + Error Fout - + Your password is too short. Uw wachtwoord is te kort. - + The passwords do not match. De wachtwoorden komen niet overeen. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck Deck Laden @@ -3418,166 +4071,567 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: Spelers: - + General Algemeen - + Starting life total: Beginnend levenstotaal: - + Game setup options Spel opzet opties - + Remember settings Instellingen onthouden - + Local game options Opties plaatselijk spel + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Kaart naam (of zoekexpressies) - + Number of hits: Hoeveelheid resultaten: - + Auto play hits Speel resultaten automatisch - + Put top cards on stack until... Plaats bovenste kaart op stack totdat... - + No cards matching the search expression exists in the card database. Proceed anyways? Er zijn geen kaarten die bij deze zoekopdracht passen in de kaartendatabase. Toch doorgaan? - + Cockatrice Cockatrice - + Invalid filter Ongeldige filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Vul je gegevens en de gegevens van de server waar je wil registreren. Je email zal gebruikt worden om je account te controleren. - + &Host: &Eigenaar: - + &Port: &Poort: - + Player &name: &Naam van speler: - + P&assword: W&achtwoord: - + Password (again): Wachtwoord (opnieuw): - + Email: Email: - + Email (again): Email (opnieuw): - + Country: Land: - + Undefined Onbekend - + Real name: Echte naam: - + Register to server Registreer op server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Registratie Waarschuwing - + Your password is too short. Je wachtwoord is te kort. - + Your passwords do not match, please try again. Uw wachtwoorden komen niet overeen, probeer opnieuw. - + Your email addresses do not match, please try again. Uw email adressen komen niet overeen, probeer opnieuw. - + The player name can't be empty. Spelersnaam kan niet leeg zijn. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3643,46 +4697,12 @@ Je email zal gebruikt worden om je account te controleren. DlgSettings - + Unknown Error loading card database Onbekende Fout in het lezen van de kaart database - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Uw kaar database is niet geldig. - -Cockatrice kan niet correct functioneren zonder een geldige database - -U zou oracle moeten herstarten om uw database te updaten. - -Zou u de locatie van uw database willen veranderen? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Je kaart database versie is te oud. - -Dit kan problemen veroorzaken tijdens het laden van kaart gegevens of afbeeldingen. - -Dit zou mogelijk opgelost worden door oracle opnieuw te starten en je kaart database te updaten. - -Wil je de locatie van je database veranderen? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3695,7 +4715,7 @@ Plaats een klacht op https://github.com/Cockatrice/Cockatrice/issues met uw card Wilt u uw database lokatie aanpassen? - + File Error loading your card database. Would you like to change your database location setting? @@ -3704,7 +4724,7 @@ Would you like to change your database location setting? Wilt u uw database lokatie aanpassen? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3713,7 +4733,7 @@ Would you like to change your database location setting? Wilt u uw database lokatie aanpassen? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3726,77 +4746,208 @@ Plaats een klacht op https://github.com/Cockatrice/Cockatrice/issues asltublieft Wilt u uw database lokatie aanpassen? - - - + + + Error Fout - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Het pad naar uw deck map is niet geldig. Wilt u terug gaan en de correcte pad invullen? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Het pad naar uw kaart afbeelding map is niet geldig. Wil je terug gaan en het juiste pad invullen? - + Settings Instellingen - + + Search settings... + + + + + OK + + + + General Algemeen - + Appearance Weergave - + User Interface Gebruikersomgeving - + Storage - + Card Sources Kaart Bronnen - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Geluid - + Shortcuts Sneltoetsen + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check Kaartbewerkingscontrole - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. @@ -3805,27 +4956,27 @@ Kies hoe je de kaartendatabasebewerker wil uitvoeren. Je kan altijd dit gedrag aanpassen in de "Algemeen" instellingtab. - + Run in foreground In voorgrond uitvoeren - + Run in background In achtergrond uitvoeren - + Run in background and always from now on Altijd in achtergrond uitvoeren vanaf nu - + Don't prompt again and don't run Niet opniew vragen en niet uitvoeren - + Don't run this time Niet nu uitvoeren @@ -3833,17 +4984,17 @@ Je kan altijd dit gedrag aanpassen in de "Algemeen" instellingtab. DlgTipOfTheDay - + Next Volgende - + Previous Vorige - + Tip of the Day Tip van de Dag @@ -3851,166 +5002,176 @@ Je kan altijd dit gedrag aanpassen in de "Algemeen" instellingtab. DlgUpdate - + Current release channel Huidig releasekanaal - + Reinstall Herinstalleer - + Cancel Download Annuleer Downloaden - + Open Download Page Open de downloadpagina - + Check for Client Updates Controleren op Client-updates - - - - + + + + Error Fout - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice is niet gebouwd met SSL-ondersteuning, daarom kunt u niet automatisch updates downloaden! Ga naar de downloadpagina om handmatig bij te werken. - + Downloading update: %1 Bewerkingen downloaden: %1 - + Checking for updates... Controleren op updates... - + Finished checking for updates Controle op updates voltooid - + No Update Available Geen Update Beschikbaar - + Cockatrice is up to date! Cockatrice is up to date! - + You are already running the latest version available in the chosen release channel. U draait al de laatste versie die beschikbaar is in het gekozen releasekanaal. - + Current version Huidige versie - + Selected release channel Geselecteerd releasekanaal - - + + Update Available Update Beschikbaar - - + + A new version of Cockatrice is available! Een nieuwe versie van Cockatrice is beschikbaar! - - + + New version Nieuwe versie - - + + Released Released - - + + Changelog Changelog - + Do you want to update now? Wilt u nu updaten? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Helaas, de automatische bewerker is niet geslaagd in het vinden van een toepasselijke download. Je moet misschien handmatig de nieuwe versie downloaden. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Controleer alsjeblieft de <a href="%1">releases pagina</a>op onze Github en download de versie voor jouw systeem. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Update Fout - + An error occurred while checking for updates: Er is een fout opgetreden bij het controleren op updates: - + An error occurred while downloading an update: Er is een fout opgetreden tijdens het downloaden van een update: - + Installing... Installeren... - + Cockatrice is unable to open the installer. Cockatrice kan het installatieprogramma niet openen. - + Try to update manually by closing Cockatrice and running the installer. Probeer handmatig bij te werken door Cockatrice te sluiten en het installatieprogramma uit te voeren. - + Download location Download locatie @@ -4018,17 +5179,17 @@ Je moet misschien handmatig de nieuwe versie downloaden. DlgViewLog - + Clear log when closing Wis log bij sluiten - + Copy to clipboard Kopieer naar klembord - + Debug Log Debug Log @@ -4266,7 +5427,7 @@ Je moet misschien handmatig de nieuwe versie downloaden. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: Zoutigheid: @@ -4274,7 +5435,7 @@ Je moet misschien handmatig de nieuwe versie downloaden. FilterBuilder - + Type your filter here Typ hier uw filter in @@ -4282,45 +5443,88 @@ Je moet misschien handmatig de nieuwe versie downloaden. FilterDisplayWidget - + Confirm Delete Bevestig Verwijderen - + Are you sure you want to delete the filter '%1'? Weet je zeker dat je de filter '%1' wil verwijderen? - + Delete Failed Verwijderen Mislukt - + Failed to delete filter '%1'. Verwijderen van filter '%1' mislukt. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator er uit geschopt door spelmaker of moderator - + player left the game speler heeft het spel verlaten - + player disconnected from server speler verbrak verbinding van server - + reason unknown reden onbekend @@ -4328,140 +5532,156 @@ Je moet misschien handmatig de nieuwe versie downloaden. GameSelector - - - - - - - - - + + + + + + + + + Error Fout - + Please join the appropriate room first. Gelieve eerst naar de juiste kamer te gaan. - + Wrong password. Verkeerd wachtwoord. - + Spectators are not allowed in this game. Toeschouwers zijn niet toegelaten in dit spel. - + The game is already full. Dit spel is reeds vol. - + The game does not exist any more. Het spel bestaat niet meer. - + This game is only open to registered users. Dit spel is alleen toegankelijk voor geregistreerde gebruikers. - + This game is only open to its creator's buddies. Dit spel is alleen toegankelijk voor de maatjes van de maker van dit spel. - + You are being ignored by the creator of this game. Je wordt genegeerd door de maker van dit spel. - + Join Game Aansluiten bij spel - + Spectate Game Spel Toeschouwen - + Game Information Spelgegevens - + + Cop&y game link + + + + Join Game as Judge Bij Spel Aansluiten als Judge - + Spectate Game as Judge Spel Toeschouwen als Judge - + + Join game Aansluiten bij spel - - Password: - Wachtwoord: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Gelieve eerst bij de betreffende kamer aan te sluiten. - + &Filter games &Filter spellen - + C&lear filter W&is filter - + C&reate C&reëer - + &Join &Aansluiten - + Join as judge Aansluiten als judge - + J&oin as spectator &Aansluiten als toeschouwer - + Join as judge spectator Aansluiten als judge toeschouwer - + Games shown: %1 / %2 Spellen weergegeven: %1 / %2 - + Games Spellen @@ -4502,105 +5722,105 @@ Je moet misschien handmatig de nieuwe versie downloaden. GamesModel - + >1 day >1 dag - + %1%2 hr short age in hours %1%2 uur%1%2 uur - + new nieuw - + %1%2 min short age in minutes %1%2 min%1%2 min - + password wachtwoord - + buddies only alleen maatjes - + reg. users only enkel geregistreerde gebruikers - + open decklists decklijsten gedeeld - - + + can chat kan praten - + see hands zie handen - + can see hands kan handen zien - + not allowed niet toegelaten - + Room Kamer - + Age Ouderdom - + Description Omschrijving - - Creator - Maker + + Host + - + Type Type - + Restrictions Restricties - + Players Spelers - + Spectators Toeschouwers @@ -4608,158 +5828,275 @@ Je moet misschien handmatig de nieuwe versie downloaden. GeneralSettingsPage - + Reset all paths Reset alle bestandspaden - + All paths have been reset Alle bestandspaden zijn gereset - - - - - - - + + + + English + Nederlands (Dutch) + + + + + + + + + Choose path Kies pad - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Taal: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Locaties (bewerken uitgeschakeld in "portable mode") - + Paths Paden - + How to help with translations Hoe met vertalen te helpen - + Decks directory: Map voor decks: - + Filters directory: Filters map: - + Replays directory: Map voor replays: - + Pictures directory: Map voor afbeeldingen: - + Card database: Kaartdatabase: - + Custom database directory: Custom database map: - + Token database: Token-database - + Update channel Updatekanaal - + Check for client updates on startup Controleren op client-updates bij opstarten - + Check for card database updates on startup Controleren op kaartendatabasebewerkingen bij opstarten - + Don't check Niet controleren - + Prompt for update Vragen om bewerking - + Always update in the background Altijd in achtergrond bewerken - + Check for card database updates every Controleren op kaartendatabasebewerkingen iedere - + days dagen - + Notify if a feature supported by the server is missing in my client Waarschuw als een functie die is ondersteund door de server mist in mijn client - + Automatically run Oracle when running a new version of Cockatrice Oracle automatisch uitvoeren bij het opstarten van een nieuwe versie van Cockatrice - + Show tips on startup Toon tips bij het opstarten - + Last update check on %1 (%2 days ago) Laatste controle op %1 (%2 dagen geleden) @@ -4767,47 +6104,47 @@ Je moet misschien handmatig de nieuwe versie downloaden. GraveyardMenu - + &Graveyard &Graveyard - + &View graveyard &Bekijk graveyard - + &Move graveyard to... &Verplaats graveyard naar... - + &Top of library &Bovenkant van library - + &Bottom of library &Onderkant van library - + &All players &Alle spelers - + &Hand &Hand - + &Exile &Exile - + Reveal random card to... Onthul willekeurige kaart aan... @@ -4901,59 +6238,353 @@ Je moet misschien handmatig de nieuwe versie downloaden. Onthul &willekeurige kaart aan.... + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck Maak Nieuw Deck - + Browse Decks Decks Doorbladeren - + Browse Card Database Kaartendatabase Doorbladeren - + Browse EDHRec EDHRec Doorbladeren - + Browse Archidekt Archidekt Doorbladeren - + View Replays Replays Inzien - + Quit Sluiten - + Connecting... Verbinden... - + Connect Verbinden - + Play Spelen + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5171,302 +6802,323 @@ Je moet misschien handmatig de nieuwe versie downloaden. MainWindow - - + + Player %1 Speler %1 - + Load replay Laad replay - + About Cockatrice Over Cockatrice - + Version Versie - + Cockatrice Webpage Cockatrice Webpagina - + Project Manager: Project Manager: - + Past Project Managers: Vroegere projectmanagers: - + Developers: Ontwikkelaars: - + Our Developers Onze ontwikkelaars - + Help Develop! Help ontwikkelen! - + Translators: Vertalers: - + Our Translators Onze Vertalers - + Help Translate! Help Vertalen! - + Support: Steun: - + Report an Issue Een probleem melden - + Troubleshooting Probleemoplossing - + F.A.Q. F.A.Q. - - + + Error Fout - + &Connect... &Verbinden.... - + &Disconnect V&erbinding verbreken - + Start &local game... Start &plaatselijk spel.... - + &Watch replay... &Bekijk replay... - + &Full screen &Volledig scherm - + &Register to server... &Registreer bij server... - + &Restore password... &Reset wachtwoord... - + &Settings... &Instellingen.... - + &Exit &Afsluiten - + A&ctions Act&ies - + &Cockatrice &Cockatrice - + C&ard Database &Kaartdatabase: - + &Manage sets... &Beheer sets... - + Edit custom &tokens... Aangepaste &tokens bewerken - + Open custom image folder Open map voor aangepaste afbeeldingen - + Open custom sets folder Open aangepaste sets map - + Add custom sets/cards Aangepaste sets/kaarten toevoegen - + Reload card database Kaartendatabase opniew laden - + Tabs Tabs - + &Help &Hulp - + &About Cockatrice &Over Cockatrice - + &Tip of the Day &Tip van de Dag - + Check for Client Updates Controleren op Client-updates - + Check for Card Updates... Kijk of er kaartupdates zijn... - + Check for Card Updates (Automatic) Controleren op kaartbewerkingen (Automatisch) - + Show Status Bar Statusbalk Weergeven - + View &Debug Log Bekijk &debug log - + Open Settings Folder Instellingsmap Openen - + + Re-run Onboarding Wizard... + + + + Show/Hide Tonen/Verbergen - + + Sign-in cancelled + + + + New Version Niewe Versie - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Gefeliciteerd met de update naar Cockatrice %1! Oracle zal nu starten om uw kaartendatabase bij te werken. - + Cockatrice installed Cockatrice geinstalleerd - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Gefeliciteerd met het installeren van Cockatrice %1! Oracle zal nu starten om uw kaartendatabase bij te werken. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Kaartdatabase - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" Cockatrice kan de kaartendatabase niet laden. - - + + Yes Ja - - + + No Nee - + Open settings Open instellingen - + New sets found Nieuwe sets gevonden - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5477,22 +7129,22 @@ Set codes: %1 Wilt u deze inschakelen? - + Yes, always enable - + View sets Bekijk sets - + Welcome Welkom - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5501,90 +7153,90 @@ Alle sets in de kaartendatabase zijn ingeschakeld. Lees meer over het wijzigen van de setvolgorde of het uitschakelen van specifieke sets en de daaruit voortvloeiende effecten in het "Beheer sets" dialoogvenster. - + Information Informatie - + A card database update is already running. Een update van de kaartendatabase is al aan de gang. - + Unable to run the card database updater: Kan het updateprogramma van de kaartendatabase niet uitvoeren: - + Card database update running. Kaartendatabasebewerking in uitvoering. - + Failed to start. The file might be missing, or permissions might be incorrect. Opstarten mislukt. Het bestand mist misschien, of toestemmingen zijn misschien incorrect. - + The process crashed some time after starting successfully. Het proces crashte nadat deze geslaagd is opgestart. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Tijd verlopen. Het proces nam te lang met antwoorden. De laatste waitfor...() functie verliep. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Een fout heeft zich voorgedaan tijdens een poging om te schrijven naar het proces. Bijvoorbeeld, het proces is niet in uitvoering misschien, of het heeft misschien het invoerkanaal gesloten. - + An error occurred when attempting to read from the process. For example, the process may not be running. Een fout heeft zich voorgedaan tijdens een poging om te lezen van het proces. Bijvoorbeeld, het proces is niet in uitvoering misschien. - + Unknown error occurred. Een onbekende fout heeft zich voorgedaan. - + The card database updater exited with an error: %1 De kaartendatabasebewerker sloot af met foutmelding. %1 - - - - - + + + + + Load sets/cards Laad sets/kaarten - + Selected file cannot be found. Geselecteerd bestand kan niet worden gevonden. - + You can only import XML databases at this time. U kunt op dit moment alleen XML-databases importeren. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. De nieuwe sets/kaarten zijn succesvol toegevoegd. Cockatrice zal nu de kaartendatabase herladen. - + Sets/cards failed to import. Sets/kaarten zijn niet geïmporteerd. @@ -5789,615 +7441,615 @@ Cockatrice zal nu de kaartendatabase herladen. MessageLogWidget - + from play vanuit spel - + from their graveyard vanuit hun graveyard. - + from exile vanuit exile - + from their hand vanuit hun hand. - + the top card of %1's library de bovenste kaart %1's library - + the top card of their library de bovenste kaart van hun library - + from the top of %1's library van de top van %1's library - + from the top of their library van de bovenkant van hun library - + the bottom card of %1's library de onderste kaart van %1's library - + the bottom card of their library de onderste kaart van hun library - + from the bottom of %1's library van de onderkant van %1's library - + from the bottom of their library van de onderkant van hun library - + from %1's library van %1's library - + from their library van hun library - + from sideboard van sideboard - + from the stack van de stack - + from custom zone '%1' vanaf aangepaste zone '%1' - + %1 is now keeping the top card %2 revealed. %1 houdt nu de bovenste kaart %2 onthuld. - + %1 is not revealing the top card %2 any longer. %1 geeft de bovenste kaart %2 niet meer weer. - + %1 can now look at top card %2 at any time. %1 kan nu altijd de bovenste kaart %2 bekijken. - + %1 no longer can look at top card %2 at any time. %1 kan nu niet meer de bovenste kaart %2 bekijken. - + %1 attaches %2 to %3's %4. %1 bevestigt %2 aan %3s %4. - + %1 has conceded the game. %1 heeft het spel opgegeven. - + %1 has unconceded the game. %1 is terug in het spel. - + %1 has restored connection to the game. %1 heeft de verbinding met het spel hersteld. - + %1 has lost connection to the game. %1 heeft de verbinding met het spel verloren. - + %1 points from their %2 to themselves. %1 wijst van hun %2 naar zichzelf. - + %1 points from their %2 to %3. %1 wijst van hun %2 naar %3. - + %1 points from %2's %3 to themselves. %1 wijst van %2s %3 naar zichzelf. - + %1 points from %2's %3 to %4. %1 wijst van %2s %3 naar %4. - + %1 points from their %2 to their %3. %1 wijst van hun %2 naar hun %3. - + %1 points from their %2 to %3's %4. %1 wijst van hun %2 naar %3s %4. - + %1 points from %2's %3 to their own %4. %1 wijst van %2s %3 naar hun eigen %4. - + %1 points from %2's %3 to %4's %5. %1 wijst van %2s %3 naar %4s %5. - + %1 creates a face down token. %1 creëert een token verdekt. - + %1 creates token: %2%3. %1 creëert een token: %2%3. - + %1 has loaded a deck (%2). %1 heeft een deck geladen (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 heeft een deck met %2 sideboard kaarten geladen (%3). - + %1 destroys %2. %1 vernietigt %2. - + a card een kaart - + %1 gives %2 control over %3. %1 geeft %2 controle over %3. - + %1 puts %2 into play%3 face down. %1 zet %2 gesloten in het spel%3. - + %1 puts %2 into play%3. %1 zet %2 in het spel%3. - + %1 puts %2%3 into their graveyard face down. %1 legt %2%3 in hun graveyard verdekt. - + %1 puts %2%3 into their graveyard. %1 legt %2%3 in hun graveyard. - + %1 exiles %2%3 face down. %1 legt %2%3 in exile verdekt. - + %1 exiles %2%3. %1 legt %2%3 in exile. - + %1 moves %2%3 to their hand. %1 verplaatst %2%3 naar hun hand. - + %1 puts %2%3 into their library. %1 legt %2%3 in hun library. - + %1 puts %2%3 onto the bottom of their library. %1 legt %2%3 aan de onderkant van hun library. - + %1 puts %2%3 on top of their library. %1 legt %2%3 op de bovenkant van hun library. - + %1 puts %2%3 into their library %4 cards from the top. %1 plaatst %2%3 in hun library %4 kaarten vanaf de bovenkant. - + %1 moves %2%3 to sideboard. %1 verplaatst %2%3 naar het sideboard. - + %1 plays %2%3 face down. %1 speelt %2%3 verdekt. - + %1 plays %2%3. %1 speelt %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 verplaatst %2%3 naar aangepaste zone '%4' verdekt. - + %1 moves %2%3 to custom zone '%4'. %1 verplaatst %2%3 naar aangepaste zone '%4'. - + %1 tries to draw from an empty library %1 probeert een kaart te nemen van een lege library - + %1 draws %2 card(s). %1 trekt een kaart%1 trekt %2 kaarten - + %1 is looking at %2. %1 is aan het kijken naar %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 kijkt naar de %4 %3 kaart %2.%1 kijkt naar de %4 %3 kaarten %2. - + bottom onderkant - + top bovenkant - + %1 turns %2 face-down. %1 draait %2 om met de voorkant naar beneden. - + %1 turns %2 face-up. %1 draait %2 om met de voorkant naar boven. - + The game has been closed. Het spel is afgesloten. - + The game has started. Het spel is begonnen. - + You are flooding the game. Please wait a couple of seconds. Je bent het spel aan het overstelpen. Wacht een paar seconden. - + %1 has joined the game. %1 heeft zich bij het spel aangesloten. - + %1 is now watching the game. %1 kijkt nu naar het spel. - + You have been kicked out of the game. Je bent uit het spel geschopt. - + %1 has left the game (%2). %1 heeft het spel verlaten (%2). - + %1 is not watching the game any more (%2). %1 kijkt niet meer naar het spel (%2). - + %1 is not ready to start the game any more. %1 is niet meer klaar om het spel te starten. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 schudt zijn of haar deck en raapt een nieuwe hand van %2 kaart.%1 schudt zijn of haar deck en raapt een nieuwe hand van %2 kaarten. - + %1 shuffles their deck and draws a new hand. %1 schudt zijn of haar deck en trekt een nieuwe hand. - + You are watching a replay of game #%1. Je kijkt naar een replay van spel #%1. - + %1 is ready to start the game. %1 is klaar om het spel te starten. - + cards an unknown amount of cards kaarten - + %1 card(s) a card for singular, %1 cards for plural één kaart%1 kaarten - + %1 lends %2 to %3. %1 leent %2 aan %3. - + %1 reveals %2 to %3. %1 onthult %2 naar %3. - + %1 reveals %2. %1 onthult %2. - + %1 randomly reveals %2%3 to %4. %1 onthult %2%3 aan %4 gebaseerd op willekeur. - + %1 randomly reveals %2%3. %1 onthult %2%3 gebaseerd op willekeur. - + %1 peeks at face down card #%2. %1 bekijkt verdekte kaart #%2. - + %1 peeks at face down card #%2: %3. %1 bekijkt verdekte kaart #%2: %3. - + %1 reveals %2%3 to %4. %1 onthult %2%3 aan %4. - + %1 reveals %2%3. %1 onthult %2%3. - + %1 reversed turn order, now it's %2. %1 keert de beurtvolgorde om, deze is nu %2. - + reversed omgekeerd - + normal normaal - + Heads Kop - + Tails Munt - + %1 flipped a coin. It landed as %2. %1 gooit een muntje om. Het landde %2. - + %1 rolls a %2 with a %3-sided die. %1 rolt een %2 met een %3-zijdige dobbelsteen. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 werpt %2 munten. Er zijn %3 koppen en %4 munten. - + %1 rolls a %2-sided dice %3 times: %4. %1 werpt een %2-zijdige dobbelsteen %3 keer: %4. - + %1's turn. %1s beurt. - + %1 sets annotation of %2 to %3. %1 verandert de annotatie van %2 naar %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 verandert teller %2 naar %3 (%4%5). - + %1 sets %2 to not untap normally. %1 zet %2 op niet tegelijk ontappen. - + %1 sets %2 to untap normally. %1 zet %2 op tegelijk ontappen. - + %1 removes the PT of %2. %1 verwijdert de stats van %2. - + %1 changes the PT of %2 from nothing to %4. %1 verandert de stats van %2 van niks naar %4. - + %1 changes the PT of %2 from %3 to %4. %1 verandert de stats van %2 van %3 naar %4. - + %1 has locked their sideboard. %1 heeft hun sideboard vergrendeld. - + %1 has unlocked their sideboard. %1 heeft hun sideboard ontgrendeld. - + %1 taps their permanents. %1 tapt alles. - + %1 untaps their permanents. %1 ontapt alles. - + %1 taps %2. %1 tapt %2. - + %1 untaps %2. %1 ontapt %2. - + %1 shuffles %2. %1 schudt %2. - + %1 shuffles the bottom %3 cards of %2. %1 schudt de onderste %3 kaarten van %2. - + %1 shuffles the top %3 cards of %2. %1 schudt de bovenste %3 kaarten van %2. - + %1 shuffles cards %3 - %4 of %2. %1 schudt kaarten %3 - %4 van %2. - + %1 unattaches %2. %1 maakt %2 los. - + %1 undoes their last draw. %1 legt hun laatst getrekte kaart terug. - + %1 undoes their last draw (%2). %1 legt hun laats getrekte kaart terug (%2). @@ -6405,115 +8057,125 @@ Cockatrice zal nu de kaartendatabase herladen. MessagesSettingsPage - + Word1 Word2 Word3 Woord1 Woord2 Woord3 - + Add New Message Bericht toevoegen - + Edit Message Bericht bewerken - + Remove Message Bericht Verwijderen - + Add message Bericht toevoegen - - + + Message: Bericht: - + Edit message Bericht bewerken - + Chat settings Chat instellingen - + Custom alert words Aanpasbare alerteringswoorden - + Enable chat mentions Schakel chat vermeldingen in - + Enable mention completer Vermeldings aanvuller inschakelen - + In-game message macros In-game bericht macro's - + How to use in-game message macros Uitleg gebruik van in-game bericht macro's - + Ignore chat room messages sent by unregistered users Chatroomberichten van niet-geregistreerde gebruikers negeren - + Ignore private messages sent by unregistered users Privéberichten van niet-geregistreerde gebruikers negeren - + Ignore private messages sent by non-buddy users - - + + Invert text color Text kleur inverteren - + Enable desktop notifications for private messages Desktopmeldingen voor privéberichten inschakelen - + Enable desktop notification for mentions Desktopmelding voor vermeldingen inschakelen - + Enable room message history on join Kamerboodschapsgeschiedenis op aansluiting inschakelen - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Kleur is hexadecimaal) - + Separate words with a space, alphanumeric characters only Woorden scheiden met een spatie, alleen alfanumerieke tekens @@ -6521,42 +8183,42 @@ Cockatrice zal nu de kaartendatabase herladen. MoveMenu - + Move to Verplaats naar - + &Top of library in random order %bovenkant library in willekeurige volgorde - + X cards from the top of library... X kaarten van bovenkant library... - + &Bottom of library in random order &Onderkant library in willekeurige volgorde - + T&able &Tafel - + &Hand &Hand - + &Graveyard &Graveyard - + &Exile &Exile @@ -6635,148 +8297,134 @@ Cockatrice zal nu de kaartendatabase herladen. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6899,30 +8547,21 @@ Cockatrice zal nu de kaartendatabase herladen. Einde van de beurt fase - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - en - - PlayerActions - - - - + + + + grave graveyard - - - - + + + + exile exile @@ -7041,16 +8680,272 @@ Cockatrice zal nu de kaartendatabase herladen. PlayerMenu - + Player "%1" Speler "%1" - + &Counters &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7075,12 +8970,12 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart PrintingSelector - + Display Navigation Buttons Navigatieknoppen weergeven - + Printing Selector Drukkiezer @@ -7088,25 +8983,65 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart PrintingSelectorCardOverlayWidget - + Preference Voorkeur - + Pin Printing Druk Vastpinnen - + Unpin Printing Druk Lospinnen - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards Gerelateerde kaarten weergeven + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7137,28 +9072,28 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart PrintingSelectorCardSortingWidget - + Alphabetical Alphabetisch - + Preference Voorkeur - + Release Date Datum van Uitgave - - + + Descending Omlaag - + Ascending Omhoog @@ -7174,95 +9109,103 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart PtMenu - + Power / toughness Kracht / hardheid - + &Increase power Ver&hoog kracht - + &Decrease power Ver&laag kracht - + I&ncrease toughness Verh&oog hardheid - + D&ecrease toughness Verl&aag hardheid - + In&crease power and toughness Verhoo&g kracht en hardheid - + Dec&rease power and toughness Verlaa&g kracht en hardheid - + Increase power and decrease toughness Verhoog kracht en verlaag hardheid - + Decrease power and increase toughness Verlaag kracht en verhoog hardheid - + Set &power and toughness... Kracht en hardheid &instellen... - + Reset p&ower and toughness &Herstel kracht en hardheid + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Diensten - + Hide %1 Verberg %1 - + Hide Others Verberg Anderen - + Show All Toon Alle - + Preferences... Voorkeuren... - + Quit %1 Stop %1 - + About %1 Over %1 @@ -7270,17 +9213,17 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart QObject - + Cockatrice card database (*.xml) Cockatrice kaartendatabase (*.xml) - + All files (*.*) Alle bestanden (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7300,17 +9243,22 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart Tokens - + + Maybeboard + + + + Overwrite Existing File? Bestaand Bestand Overschrijven? - + A .cod version of this deck already exists. Overwrite it? Een .cod versie van dit deck bestaat al. Deze overschrijven? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7327,7 +9275,7 @@ Je zal de Setbeheerder moeten gebruiken, te vinden bij Kaartendatabase -> Beh Weet je zeker dat je deze functie wil inschakelen? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7344,100 +9292,110 @@ Je ook de Setbeheerder gebruiken om de sorteervolgorde voor de drukken in de Dru Weet je zeker dat je deze functie wil uitschakelen? - + Confirm Change Bevestig Wijziging + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Opslaan - + Save All Alles Opslaan - + Open Open - + &Yes &Ja - + Yes to &All Ja op A&lle - + &No &Nee - + N&o to All Nee op &Alle - + Abort Afbreken - + Retry Opnieuw proberen - + Ignore Negeren - + Close Sluiten - + Cancel Annuleren - + Discard Afleggen - + Help Hulp - + Apply Toepassen - + Reset Resetten - + Restore Defaults Standaardinstellingen herstellen @@ -7445,121 +9403,189 @@ Weet je zeker dat je deze functie wil uitschakelen? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Naam - + ID ID - + Upload time Upload tijd + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Naam - + Players Spelers - + Keep Bewaar - + Time started Tijd begonnen - + Duration (sec) Duur (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7601,37 +9627,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Kamers - + Joi&n &Deelnemen - + Room Kamer - + Description Omschrijving - + Permissions Toestemmingen - + Players Spelers - + Games Spellen @@ -7639,7 +9665,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay &Zeg @@ -7647,32 +9673,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Kies een actie uit de tabel - + Hit the key/combination of keys you want to set for this action Druk op de toets/combinatie van toetsen die je voor deze functie wilt gebruiken. - + Invalid key Ongeldige toets - + Shortcut already in use by: Sneltoets reeds in gebruik door: - + Clear Wis - + Restore default Herstel standaard @@ -7705,56 +9731,92 @@ Fine-tune individual colours in the grid afterwards. releasedatum + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Alle standaard snelkoppelingen herstellen - + Do you really want to restore all default shortcuts? Wilt u echt alle standaard snelkoppelingen herstellen? - + Clear all default shortcuts Alle standaard snelkoppelingen wissen - + Do you really want to clear all shortcuts? Wil je echt alle snelkoppelingen vrijmaken? - + Section: Sectie: - + Action: Functie: - + Shortcut: Sneltoets: - + + Shortcut editor + + + + How to set custom shortcuts Hoe aangepaste snelkoppelingen in te stellen - + Clear all shortcuts Alle sneltoetsen wissen - + Search by shortcut name Zoek op sneltoetsnaam @@ -7810,12 +9872,12 @@ Controleer uw snelkoppelingsinstellingen! SideboardMenu - + &Sideboard &Sideboard - + &View sideboard &Bekijk sideboard @@ -7823,27 +9885,27 @@ Controleer uw snelkoppelingsinstellingen! SoundSettingsPage - + Enable &sounds &Geluiden inschakelen - + Current sounds theme: Huidige geluiden thema: - + Test system sound engine Test geluid engine van systeem - + Sound settings Geluidsinstellingen - + Master volume Hoofdvolume @@ -7851,48 +9913,48 @@ Controleer uw snelkoppelingsinstellingen! SpoilerBackgroundUpdater - + Spoilers season has ended Spoilers seizoen is afgelopen - + Deleting spoiler.xml. Please run Oracle Verwijdert spoiler.xml. Gelieve Oracle te draaien - - + + Spoilers download failed Spoilers downloaden mislukt - + No internet connection Geen internetverbinding - + Error Fout - + Spoilers already up to date Spoilers reeds up-to-date - + No new spoilers added Geen nieuwe spoilers toegevoegd - + Spoilers have been updated! Spoilers zijn bijgewerkt! - + Last change: Laatste Wijziging: @@ -7900,157 +9962,170 @@ Controleer uw snelkoppelingsinstellingen! StableReleaseChannel - + Default Standaard - + No reply received from the release update server. Geen antwoord ontvangen van de release update server. - + Invalid reply received from the release update server. Ongeldig antwoord ontvangen van de release update server. - + No reply received from the tag update server. Geen antwoord ontvangen van de tag update server. - + Invalid reply received from the tag update server. Ongeldig antwoord ontvangen van de tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8058,17 +10133,33 @@ Controleer uw snelkoppelingsinstellingen! TabAccount - + + + My Reports + + + + Add to Buddy List Aan Maatjeslijst toevoegen - + Add to Ignore List Voeg toe aan Negeerlijst - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account Account @@ -8189,118 +10280,118 @@ Controleer uw snelkoppelingsinstellingen! TabArchidekt - - - + + + Desc. Afl. - - + + AND EN - - + + Require ALL selected colors Vereis ALLE gekozen kleuren - - + + Deck name... Decknaam... - - + + Owner... Eigenaar... - - + + Packages Pakketten - - + + Advanced Filters Geavanceerde Filters - + Bracket: Bracket: - - + + Any Geen Voorkeur - - + + Contains card... Bevat kaart... - - + + Commander... Commander... - - + + Tag... Tag... - - + + Deck Size Deckgrootte - + Cards: Kaarten: - - + + Asc. Opl. - + Sort by: Sorteer op: - + Filter by: Filter op: - + Display Settings Weergavensinstellingen - - + + Search Zoeken - - + + Formats Formats @@ -8313,42 +10404,42 @@ Controleer uw snelkoppelingsinstellingen! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8361,52 +10452,52 @@ Controleer uw snelkoppelingsinstellingen! TabDeckEditor - + Card Info Kaart info - + Deck Deck - + Filters Filters - + &View &Bekijk - + Card Database Kaartendatabase - + Printing Druk - + Visible Zichtbaar - + Floating Zwevend - + Reset layout Indeling resetten - + Deck: %1 Dek: %1 @@ -8414,49 +10505,49 @@ Controleer uw snelkoppelingsinstellingen! TabDeckEditorVisual - + Visual Deck: %1 Visueel Deck: %1 - + &Visual Deck Editor &Visuele Deckbewerker - - + + Card Info Kaarteninfo - - + + Deck Deck - + &View &Bekijk - + Printing Druk - + Visible Zichtbaar - + Floating Zwevend - + Reset layout Herstel indeling @@ -8464,22 +10555,22 @@ Controleer uw snelkoppelingsinstellingen! TabDeckEditorVisualTabWidget - + Visual Deck View Visuele Deckweergave - + Visual Database Display Visuele Databaseweergave - + Deck Analytics Deckanalyses - + Sample Hand Proefhand @@ -8487,138 +10578,236 @@ Controleer uw snelkoppelingsinstellingen! TabDeckStorage - + Local file system Plaatselijk bestandssysteem - + Server deck storage Dekopslag op de server - - + + Open in deck editor Open deck in bewerker - + Rename deck or folder Hernoem deck of map - + Upload deck Upload dek - + Download deck Download dek - - - - + + + + New folder Nieuwe map - - + + Delete Verwijder - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Deckmap openen - + Rename local folder Hernoem plaatselijke map - + Rename local file Hernoem plaatselijk bestand - + New name: Nieuwe naam: - - - - + + + + + + + Error Foutmelding - + Rename failed Hernoemen mislukt - - + + + Invalid deck file Ongeldig deck bestand - + Enter deck name Geeft dek naam in - + This decklist does not have a name. Please enter a name: De deklijst heeft geen naam. Geef een naam in aub. - + Unnamed deck Onbenoemd dek - + Failed to upload deck to server Uploaded van deck naar server mislukt - + Delete local file Verwijder plaatselijk bestand - + Are you sure you want to delete the selected files? Weet je zeker dat je de opgeslagen bestanden wil verwijderen? - + Delete remote decks Verwijder deck van server - + Are you sure you want to delete the selected decks? Weet je zeker dat je de geselekteerde decks wil verwijderen? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Naam van de map: - + Deck Storage Deckopslag @@ -8626,20 +10815,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage Visuele Deckopslag - + Error Foutmelding - + Could not open deck at %1 Kon deck op %1 niet openen + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8652,27 +11092,27 @@ Please enter a name: TabEdhRecMain - + &Cards &Kaarten - + Top Commanders Top Commanders - + Tags Tags - + Search for a card ... Zoeken op kaart... - + Search Zoeken @@ -8685,191 +11125,207 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Spel - - + + Player List Spelerslijst - - + + + Invite + + + + + Card Info Kaartinfo - - + + Messages Berichten - - + + Replay Timeline Replay Tijdslijn - + &Phases Fases - + &Game Spel - + Next &phase Volgende fase - + Next phase with &action Volgende fase met &handeling - + Next &turn Volgende beurt - + Reverse turn order Keer beurtvolgorde om - + &Remove all local arrows Verwijder alle plaatselijke pijlen - + Rotate View Cl&ockwise Draai de weergave met de klok &mee - + Rotate View Co&unterclockwise Draai de weergave &tegen de klok in - + Game &information Spelgegevens - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede Opgeven &terugdraaien - - - + + + &Concede &Geef op - + &Leave game Verlaat spel - + C&lose replay Sluit opgeslagen spel - + &Focus Chat &Focus Chat - + &Say: Zeg: - + Selected cards Geselecteerde kaarten - + &View &Bekijk - + Visible Zichtbaar - + Floating Zwevend - + Reset layout Reset indeling - + Concede Opgeven - + Are you sure you want to concede this game? Ben je zeker dat je wilt opgeven voor dit spel? - + Unconcede Opgeven ongedaan maken - + You have already conceded. Do you want to return to this game? Je hebt al opgegeven. Wil je nar dit spel terugkeren? - + Leave game Verlaat spel - + Are you sure you want to leave this game? Ben je zeker dat je dit spel wilt verlaten? - + A player has joined game #%1 Een speler sluitte aan bij spel #%1 - + %1 has joined the game %1 sluitte bij het spel aan - + You have been kicked out of the game. Je bent uit het spel gegooid. @@ -8885,158 +11341,158 @@ Please enter a name: TabLog - + Logs Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Tijd;Afzendernaam;AfzenderIP; Bericht;DoelID;DoelNaam - + Room Logs Kamerlogboeken - + Game Logs Spellogboeken - + Chat Logs Chat Logs - - + + Error Fout - + You must select at least one filter. U moet ten minste één filter selecteren. - + You have to select a valid number of days to locate. U moet een geldig aantal dagen selecteren om te zoeken. - + Username: Gebruikersnaam: - + IP Address: IP-adres: - + Game Name: Spelnaam: - + GameID: GameID: - + Message: Boodschap: - + Main Room Hoofdkamer - + Game Room Spelkamer - + Private Chat Privéchat - + Past X Days: Afgelopen X dagen: - + Today Vandaag - + Last Hour Afgelopen uur - + Maximum Results: Maximale resultaten: - + At least one filter is required. The more information you put in, the more specific your results will be. Er is minstens één filter nodig. Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. - + Get User Logs Gebruikerslogboeken ontvangen - + Clear Filters Wis filters - + Filters Filters - + Log Locations Loglokaties - + Date Range Datumbereik - + Maximum Results Maximale resultaten - - + + Message History Bericht Geschiedenis - + Failed to collect message history information. Het is mislukt om informatie over de berichtgeschiedenis te verzamelen. - + There are no messages for the selected filters. Er zijn geen berichten voor de geselecteerde filters. @@ -9044,219 +11500,559 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. TabMessage - + Private &chat Privaat gesprek - + &Leave Verlaat - + %1 - Private chat %1 - Privaat gesprek - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Deze gebruiker negeert u, ze kunnen uw berichten niet zien in de hoofdchat en u kunt niet deelnemen aan hun spelletjes. - + Private message from Privé bericht van - + %1 has left the server. %1 heeft zich afgemeld van de server. - + %1 has joined the server. %1 heeft zich aangemeld op de server + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Plaatselijk bestandssysteem - + Server replay storage Opslag gespeelde spellen op server - - + + Watch replay Bekijk opgeslagen spel - + Rename Hernoemen - - + + New folder Nieuwe map - - + + Delete Verwijder - + Open replays folder Open replays map - + Download replay Download opgeslagen spel. - + Toggle expiration lock Schakel expiratieslot om - + Get replay share code Ontvang replay-deelcode - - + + Look up replay by share code Replay opzoeken met replay-deelcode - + Rename local folder Hernoem plaatselijke map - + Rename local file Hernoem plaatselijk bestand - + New name: Nieuwe naam: - + Error Foutmelding - + Rename failed Hernoemen mislukt - + Name of new folder: Naam van nieuwe map: - + Delete local file Verwijder plaatselijk bestand - + Are you sure you want to delete the selected files? Weet je zeker dat je de geselecteerde bestanden wil verwijderen? - + Are you sure you want to delete the selected replays? Weet je zeker dat je de geselecteerde replays wil verwijderen? - + Failed to get code Ontvangen code mislukt - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Deze server ondersteund het delen van replays niet of deze staat het niet toe aan jou. - - - + + + Failed Mislukt - + Could not get replay code Ontvangen replay-code niet gelukt - + Replay Share Code Replay-deelcode - + Others can use this code to add the replay to their list of remote replays: %1 Anderen kunnen deze code gebruiken om de replay aan hun lijst van serverreplays toe te voegen: %1 - + Copy to clipboard Kopieer naar klembord - + Replay share code Replay-deelcode - + Replay code found Replay-code gevonden - + Replay was added, or you already had access to it. Replay toegevoegd, of je had misschien al toegang tot deze. - + Replay code not found Replay-code niet gevonden - + Failed to submit code Versturen code mislukt - + Unexpected error Onverwachte foutmelding - + Delete remote replay Verwijder op server opgeslagen spel @@ -9266,65 +12062,478 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn.Spel-replays + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: Zeg: - + Chat Gesprek - + &Room Kamer - + &Leave room Verlaat kamer - + &Clear chat Maak gespreksgeschiedenis leeg - + Chat Settings... Gespreksinstellingen... - + mentioned you. heeft je genoemd. - + Click to view Klik om weer te geven - + You are flooding the chat. Please wait a couple of seconds. Je bent de chat aan het overstelpen. Wacht enkele seconden a.u.b. @@ -9332,35 +12541,35 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. TabServer - + Server Server - - - - + + + + Error Fout - + Failed to join the server room: it doesn't exist on the server. Aansluiten bij kamer mislukt: deze bestaat niet op de server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. De server denkt dat je in de kamer bent, maar de client is niet in staat om deze te tonen. Probeer de client opnieuw te starten. - + You do not have the required permission to join this server room. U heeft niet de vereiste toestemming om bij deze kamer aan te sluiten. - + Failed to join the server room due to an unknown error: %1. Mislukt om bij de kamer aan te sluiten door een onbekende fout: %1. @@ -9368,97 +12577,173 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. TabSupervisor - + Deck Editor Deckbewerker - + Visual Deck Editor Visuele Deckbewerker - + EDHRec EDHRec - + Archidekt Archidekt - + Home Thuis - + &Visual Deck Storage &Visuele Deckopslag - + Visual Database Display Visuele Databaseweergave - + Server Server - + Account Account - + Deck Storage Deckopslag - + Game Replays Spel-replays - + Administration Beheer - + Logs Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Weet je het zeker? - + There are still open games. Are you sure you want to quit? Er staan nog spellen open. Ben je zeker dat je wilt afsluiten? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Klik om weer te geven - + Your buddy %1 has signed on! Je maatje %1 heeft in gelogd! - + Unknown Event Onbekend Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9469,39 +12754,39 @@ Dit bericht kan betekenen dat er een nieuwe versie van Cockatrice beschikbaar is Om uw client bij te werken, ga naar Hulp -> Controleer op updates. - + Idle Timeout Inactiviteit Time-out - + You are about to be logged out due to inactivity. U staat op het punt uitgelogd te worden wegens inactiviteit. - + Promotion Promotie - + You have been promoted. Please log out and back in for changes to take effect. Je bent bevorderd. Gelieve uit te loggen en weer in te loggen om de wijzigingen in werking te laten treden. - + Warned Gewaarschuwd - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. U heeft een waarschuwing ontvangen wegens %1. Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties tegen u worden ondernomen. Als u vragen heeft, kunt u een privé-bericht sturen naar een moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) U heeft het volgende bericht van de server ontvangen. @@ -9521,6 +12806,34 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties Visuele Databaseweergave + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9535,6 +12848,65 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties Niet in staat om het dek te analyseren. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9606,57 +12978,52 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9664,216 +13031,266 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties UserContextMenu - + User &details Gebruikersdetails - + Private &chat Privaat gesprek - + Show this user's &games Toon spellen van deze gebruiker - + + View this user's &public decks + + + + Add to &buddy list Voeg toe aan maatjeslijst - + Remove from &buddy list Verwijder uit maatjeslijst - + Add to &ignore list Voeg toe aan negatielijst - + Remove from &ignore list Verwijder uit negatielijst - + Kick from &game Gooi uit spel - + + Report user + + + + Warn user Waarschuw gebruiker - + View user's war&n history Bekijk waarschuwings&geschiedenis van gebruiker - + Ban from &server Verban van de &server - + View user's &ban history Bekijk &bangeschiedenis van gebruiker - + &Promote user to moderator &Bevorder gebruiker tot moderator - + Dem&ote user from moderator Demoteer gebruiker van moderator - + Promote user to &judge Bevorder gebruiker tot &judge - + Demote user from judge Demoteer gebruiker van judge - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes Bekijk adminnotities - - + Investigate user + + + + + + Error Foutmelding - + This user does not exist. Deze gebruiker bestaat niet. - + You are being ignored by %1 and can't see their games. Je wordt genegeerd door %1 en je kan diens spellen niet zien. - + Could not get %1's games. Kon %1's spellen niet ophalen. - + %1's games %1's spellen - - - + + + Ban History Ban Geschiedenis - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Ban Tijd;Moderator;Ban Lengte; Ban Reden; Zichtbare Reden - + User has never been banned. De gebruiker is nog nooit gebant. - + Failed to collect ban information. Mislukt in het verzamelen van gegevens over ban. - - - + + + Warning History Waarschuwingsgeschiedenis - + Warning Time;Moderator;User Name;Reason Waarschuwingstijd;Moderator;Gebruikersnaam;Reden - + User has never been warned. De gebruiker is nog nooit gewaarschuwd. - + Failed to collect warning information. Mislukt in het verzamelen van gegevens over waarschuwing. - + Failed to get admin notes. Ontvangen adminnotities mislukt. - - + + Success Succesvol - + Successfully promoted user. Succesvolle promotie van gebruiker. - + Successfully demoted user. Succesvolle demotie van gebruiker. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Mislukt - + Failed to promote user. Mislukt in het bevorderen van gebruiker. - + Failed to demote user. De gebruiker is niet gedemoteerd. - + Copy hash to clipboard Kopieer hash naar klembord - + Remove this user's messages Verwijder de berichten van deze gebruiker @@ -9911,61 +13328,61 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties Administrator - + Moderator Moderator - + Registered user Geregistreerde gebruiker - - + + Unregistered user Ongeregistreerde gebruiker - + Judge Judge - + Unknown Onbekend - + The entered password does not match your account. Het opgegeven wachtwoord komt niet overeen met jouw account. - - - - + + + + Information Gegevens - + User information updated. Gebruikersgegevens bijgewerkt. - - - - - - + + + + + - - - + + + + Error Fout @@ -9989,283 +13406,292 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n jaar, %n jaar, - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n dag %20%10%n Dagen %20 - + Enter Password Wachtwoord Invoeren - + Password verification is required in order to change your email address Wachtwoord controle is vereist om je e-mailadres te veranderen - - - + + + An error occurred while trying to update your user information. Er is een fout opgetreden bij het bijwerken van uw gebruikersgegevens. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Deze server staat jou niet toe om je gebruikersgegevens bij te werken. - + Password changed. Wachtwoord gewijzigd. - + This server does not permit you to change your password. Deze server staat u niet toe uw wachtwoord te wijzigen. - + The new password is too short. Het nieuwe wachtwoord is te kort. - + The old password is incorrect. Het oude wachtwoord is onjuist. - + Avatar updated. Avatar bijgewerkt. - + This server does not permit you to update your avatar. Deze server laat je niet toe om je avatar bij te werken. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Deze server staat jou niet toe om je gebruikersgegevens bij te werken. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10273,223 +13699,344 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties UserInterfaceSettingsPage - + General interface settings Algemene weergave-instellingen - + &Double-click cards to play them (instead of single-click) Dubbelklik op kaarten om ze te spelen (in plaats van enkele klik) - + &Clicking plays all selected cards (instead of just the clicked card) &Klikken speelt alle geselecteerde kaarten (in plaats van allen de geklikte kaart) - + &Play all nonlands onto the stack (not the battlefield) by default Speel standaard alle niet-landen op de stapel (niet op het slagveld) - + Do not delete &arrows inside of subphases Verwijder &pijlen niet binnen subphases - + Close card view window when last card is removed Sluit kaartenweergavevenster wanner laatste kaart verwijderd is - + Auto focus search bar when card view window is opened Focus zoekbalk onmiddelijk wanneer kaartenweergavevenster is geopent - + Annotate card text on tokens Maak aantekeningen bij de kaarttekst op tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Gebruik tear-off menus, laat rechtermuisknopmenu's op het scherm staan - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Notificatie instellingen - + Enable notifications in taskbar Activeer meldingen in de taakbalk - + Notify in the taskbar for game events while you are spectating Meld spelgebeurtenissen in de taakbalk terwijl je toekijkt - + Notify in the taskbar when users in your buddy list connect Notificatie in de taakbalk wanneer gebruikers in je buddy lijst verbinding maken - + Animation settings Animatie-instellingen - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &Tap/Untap animatie - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings Deckbewerker/opslag instellingen - + Open deck in new tab by default Deck standaard in niewe tab openen - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby Visuele deckopslag gebruiken in spelontvangstscherm - + Use selection animation for Visual Deck Storage Gebruik selectieanimatie voor Visuele Deckopslag - + When adding a tag in the visual deck storage to a .txt deck: Wanner een tag aan een .txt deck wordt toegevoegd in de visuele deckopslag: - - do nothing - niks doen + + Do nothing + - - ask to convert to .cod - vragen om naar .cod om te zetten + + Ask to convert to .cod + - - always convert to .cod - altijd omzetten naar .cod + + Always convert to .cod + - + Default deck editor type Standaard deckbewerker soort - + Classic Deck Editor Klassieke Deckbewerker - + Visual Deck Editor Visuele Deckbewerker - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Replayinstellingen - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10526,22 +14073,22 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10549,72 +14096,72 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10716,7 +14263,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10724,19 +14271,19 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10782,28 +14329,28 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10811,43 +14358,43 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups Klik en sleep om de sorteervolgorde binnen de groepen aan te passen - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10863,12 +14410,12 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10876,17 +14423,17 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10894,7 +14441,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10902,52 +14449,57 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10955,7 +14507,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10963,22 +14515,22 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10986,17 +14538,22 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -11004,177 +14561,202 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties WarningDialog - + Which warning would you like to send? Welke waarschuwing wilt u sturen? - + Redact all messages from this user in all rooms Verwijder alle berichten van deze gebruiker in alle kamers - + &OK &OK - + &Cancel &Cancel - + Warn user for misconduct Waarschuw gebruiker voor wangedrag - - + + Error Fout - + User name to send a warning to can not be blank, please specify a user to warn. Gebruikersnaam om een waarschuwing naar te sturen kan niet leeg zijn, geef een gebruiker op om te waarschuwen. - + Warning to use can not be blank, please select a valid warning to send. Te gebruiken waarschuwing kan niet leeg zijn, selecteer een geldige waarschuwing om te verzenden. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Verplaats de geselecteerde set naar boven - + Move selected set up Verplaats de geselecteerde set naar boven - + Move selected set down Verplaats de geselecteerde set naar beneden - + Move selected set to the bottom Verplaats de geselecteerde set naar beneden - - Search by set name, code, or type - Zoeken op setnaam, -code of -type - - - + Default order Standaard volgorde - + Restore original art priority order Herstel oorspronkelijke volgorde prioriteit afbeeldingen - + Enable all sets Alle sets inschakelen - + Disable all sets Alle sets uitschakelen - + Enable selected set(s) Geselecteerde set(s) inschakelen - + Disable selected set(s) Geselecteerde set(s) uitschakelen - + Deck Editor Deckbewerker - + Use CTRL+A to select all sets in the view. Gebruik CTRL+A om alle sets in de lijst te selecteren. - + Only cards in enabled sets will appear in the card list of the deck editor. Alleen kaarten in de ingeschakelde sets zullen verschijnen in de kaartlijst van de deckbewerker. - + Image priority is decided in the following order: De prioriteit voor afbeeldingen wordt in de volgende volgorde vastgesteld: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki eerst de CUSTOM Map (%1), daarna de Ingeschakelde Sets in deze dialoog (Boven naar Beneden) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Afbeelding - + + Search by set name, code, type, or release date + + + + How to use custom card art Hoe aangepaste kaart afbeeldingen te gebruiken - + Hints Tips - + Note Opmerking - + Sorting by column allows you to find a set while not changing set priority. Sorteren op kolom stelt u in staat om een set te vinden zonder de ingestelde prioriteit te wijzigen. - + To enable ordering again, click the column header until this message disappears. Om rangschikken weer mogelijk te maken, klik op de kop van de kolom totdat dit bericht verdwijnt. - + Use the current sorting as the set priority instead Gebruik de huidige sortering als de ingestelde prioriteit - + Sorts the set priority using the same column Sorteert de prioriteit van sets met behulp van dezelfde kolom - + Manage sets Beheer sets @@ -11182,93 +14764,85 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing Schudden bij afsluiten - + pile view Stapelweergave - - i18n - - - English - Nederlands (Dutch) - - main - + Connect on startup Verbinding maken bij het opstarten - + Debug to file Debug naar bestand @@ -12052,6 +15626,16 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties Graveyard Graveyard + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_nl.ts b/oracle/translations/oracle_nl.ts index 3fd3cc543..7558e7281 100644 --- a/oracle/translations/oracle_nl.ts +++ b/oracle/translations/oracle_nl.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Introductie - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Deze wizard importeert de lijst met sets, kaarten en tokens die door Cockatrice zullen worden gebruikt. - + Interface language: Interface taal: - + Version: Versie: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Bron selectie - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Gelieve een compatibele bron te vermelden voor de lijst van sets en kaarten. U kunt een URL-adres opgeven dat wordt gedownload of een bestaand bestand van uw computer gebruiken. - + Download URL: Download URL: - + Local file: Lokaal bestand - + Restore default URL Herstel standaard URL - + Choose file... Bestand kiezen... - + Load sets file Bestand laden - + Sets file (%1) Sets JSON file (%1) Sets bestand (%1) - - - - - - - + + + + + Error Fout - + The provided URL is not valid. De ingevoerde URL is niet geldig. - - Downloading (0MB) - Downloaden (0MB) - - - + Please choose a file. Gelieve een bestand te kiezen. - + Cannot open file '%1'. Bestand '%1' kan niet geopend worden. - - Downloading (%1MB) - Downloaden (%1MB) - - - + Network error: %1. Netwerk fout: %1. - + Parsing file Parsen van bestand - + Xz extraction failed. Xz extractie mislukt. - + Sorry, this version of Oracle does not support xz compressed files. Sorry, deze versie van Oracle ondersteunt geen xz gecomprimeerde bestanden. - + Failed to open Zip archive: %1. Zip archief kan niet geopend worden: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Uitpakken van Zip niet gelukt: het archief moet precies één bestand bevatten. - + Zip extraction failed: %1. Uitpakken van Zip niet gelukt: %1. - + Sorry, this version of Oracle does not support zipped files. Sorry, deze versie van Oracle ondersteunt geen gecomprimeerde bestanden. - + Failed to interpret downloaded data. Lezen van gedownloade data mislukt. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Wilt u het ongecomprimeerde bestand downloaden? - + + The file was retrieved successfully, but it does not contain any sets data. Het bestand is succesvol binnengehaald, maar bevat geen set data. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Opslaan spoiler database - + XML; spoiler database (*.xml) XML; spoiler database (*.xml) - + spoiler - + Spoilers import Spoilers import - + Please specify a compatible source for spoiler data. Gelieve een compatibele bron voor spoilergegevens te specificeren. - + Download URL: Download URL: - + Local file: - + Restore default URL Herstel standaard URL - + Choose file... - + The spoiler database will be saved at the following location: De spoiler database wordt op de volgende locatie opgeslagen: - + Save to a custom path (not recommended) Opslaan naar een aangepaste locatie (niet aanbevolen) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database token database opslaan - + XML; token database (*.xml) XML; token database (*.xml) - + tokens - + Tokens import Tokens import - + Please specify a compatible source for token data. Gelieve een compatibele bron voor tokengegevens op te geven. - + Download URL: Download URL: - + Local file: - + Restore default URL Herstel standaard URL - + Choose file... - + The token database will be saved at the following location: De token database wordt op de volgende locatie opgeslagen: - + Save to a custom path (not recommended) Opslaan naar een aangepaste locatie (niet aanbevolen) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Token voorbeeldset @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle importer @@ -292,22 +306,22 @@ OutroPage - + Finished Klaar - + The wizard has finished. De wizard is klaar. - + You can now start using Cockatrice with the newly updated cards. U kunt nu beginnen met het gebruik van Cockatrice met de nieuw bijgewerkte kaarten. - + If the card databases don't reload automatically, restart the Cockatrice client. Als de kaartendatabases niet automatisch herladen, start dan de Cockatrice client opnieuw op. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Fout - + + No set has been imported. Er zijn geen sets geïmporteerd. - + Sets imported Sets geïmporteerd - - A cockatrice database file of %1 MB has been downloaded. - Een cockatrice database bestand van %1 MB is gedownload. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: De volgende sets zijn gevonden: - + Press "Save" to store the imported cards in the Cockatrice database. Druk op "Opslaan" om de geïmporteerde kaarten op te slaan in de Cockatrice database. - + The card database will be saved at the following location: De kaartendatabase wordt op de volgende locatie opgeslagen: - + Save to a custom path (not recommended) Opslaan naar een aangepaste locatie (niet aanbevolen) - + &Save &Opslaan - + + Import finished: %1 cards. Import klaar: %1 kaarten. - + + %1: %2 cards imported %1: %2 kaarten geïmporteerd - + Save card database Kaartendatabase opslaan - + XML; card database (*.xml) XML; kaart database (*.xml) - + The file could not be saved to %1 Het bestand kon niet worden opgeslagen in %1 @@ -414,8 +431,9 @@ - Downloading (0MB) - Downloaden (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,7 +605,7 @@ i18n - + English Nederlands (Dutch) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Alleen in spoiler-modus werken - + Run in no-confirm background mode Uitvoeren in achtergrondmodus zonder bevestiging From 72f0a8975fb7696fc6ab684f5a7bffcfce2fa3ae Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:16:21 +0200 Subject: [PATCH 051/247] Translate oracle_en@source.ts in tr [Manual Sync] (#7564) 28% of minimum 5% translated source file: 'oracle_en@source.ts' on 'tr'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- oracle/translations/oracle_tr.ts | 200 +++++++++++++++++-------------- 1 file changed, 109 insertions(+), 91 deletions(-) diff --git a/oracle/translations/oracle_tr.ts b/oracle/translations/oracle_tr.ts index e65124c40..da48b106b 100644 --- a/oracle/translations/oracle_tr.ts +++ b/oracle/translations/oracle_tr.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Açıklama - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Bu sihirbaz Cockatrice tarafından kullanılacak setlerin, kartların ve tokenlerin listesini içe aktaracaktır. - + Interface language: Arayüz dili: - + Version: Sürüm: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Kaynak seçimi - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Lütfen set ve kart listesi için uyumlu bir kaynak belirtin. İndirilecek bir URL adresi belirtebilir veya bilgisayarınızdaki mevcut bir dosyayı kullanabilirsiniz. - + Download URL: İndirme Bağlantısı: - + Local file: Yerel dosya: - + Restore default URL Varsayılan URL'yi geri yükle - + Choose file... Dosya seç... - + Load sets file Set dosyasını yükle - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Hata - + The provided URL is not valid. Paylaşılan bağlantı geçerli değil. - - Downloading (0MB) - İndiriliyor (0MB) - - - + Please choose a file. Lütfen dosya seçin. - + Cannot open file '%1'. Dosya açılamıyor '1%'. - - Downloading (%1MB) - İndiriliyor (%1MB) - - - + Network error: %1. Bağlantı hatası: %1 - + Parsing file Dosya ayrıştırılıyor - + Xz extraction failed. Xz çıkarma işlemi başarısız oldu. - + Sorry, this version of Oracle does not support xz compressed files. Maalesef, Oracle'ın bu sürümü xz sıkıştırılmış dosyaları desteklemiyor. - + Failed to open Zip archive: %1. Zip dosyası açılamadı: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Zip çıkarma işlemi başarısız oldu: Zip arşivi tam olarak bir dosya içermiyor. - + Zip extraction failed: %1. Zip çıkarma işlemi başarısız oldu: %1. - + Sorry, this version of Oracle does not support zipped files. Maalesef, Oracle'ın bu sürümü zip dosyalarını desteklemiyor. - + Failed to interpret downloaded data. İndirilen veriler işlenemedi. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Bunun yerine sıkıştırılmamış dosyayı indirmek ister misiniz? - + + The file was retrieved successfully, but it does not contain any sets data. Dosya başarıyla alındı, ancak herhangi bir set verisi içermiyor. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Spoiler veritabanını kaydet - + XML; spoiler database (*.xml) XML; spoiler veritabanı (*.xml) - + spoiler - + Spoilers import Spoilers içe aktar - + Please specify a compatible source for spoiler data. Lütfen spoiler verileri için uyumlu bir kaynak belirtin. - + Download URL: İndirme Bağlantısı: - + Local file: - + Restore default URL Varsayılan bağlantıyı geri yükle - + Choose file... - + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + Tokens import - + Please specify a compatible source for token data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer @@ -292,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error - + + No set has been imported. - + Sets imported - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. - + + %1: %2 cards imported - + Save card database - + XML; card database (*.xml) - + The file could not be saved to %1 @@ -414,7 +431,8 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) @@ -587,7 +605,7 @@ i18n - + English Türkçe (Turkish) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode From 2d4aa4414a081bab897d420c2f433a69614b27f7 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:18:18 +0200 Subject: [PATCH 052/247] Updates for project Cockatrice and language zh-Hans (#7565) * Translate oracle_en@source.ts in zh-Hans [Manual Sync] 92% of minimum 5% translated source file: 'oracle_en@source.ts' on 'zh-Hans'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in zh-Hans [Manual Sync] 42% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'zh-Hans'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_zh-Hans.ts | 7502 ++++++++++++----- oracle/translations/oracle_zh-Hans.ts | 228 +- 2 files changed, 5666 insertions(+), 2064 deletions(-) diff --git a/cockatrice/translations/cockatrice_zh-Hans.ts b/cockatrice/translations/cockatrice_zh-Hans.ts index a2df2035e..827c25f06 100644 --- a/cockatrice/translations/cockatrice_zh-Hans.ts +++ b/cockatrice/translations/cockatrice_zh-Hans.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &设置指示物 @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 设置指示物 - + New value for counter '%1': 数值物新价值为 '%1' @@ -36,61 +36,110 @@ AbstractTabDeckEditor - + Open in new tab - + 在新标签内打开 - + Are you sure? - + 是否确定? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + 出错 - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck + 保存套牌 + + + + + Share deck - + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. + 未能保存套牌。 + + + + There are no cards in your deck to be exported + + + + + AccountSetupPage + + + Join a Server - - There are no cards in your deck to be exported + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes 更新后笔记 - + Admin Notes for %1 %1 的管理笔记 @@ -130,224 +179,310 @@ Please check that the directory is writable and try again. Tokens - + 衍生物 AppearanceSettingsPage - + seconds 秒 - + Error 出现错误 - + Could not create themes directory at '%1'. 不能在'%1'创造主题目录 - + Theme settings 主题设定 - + Current theme: 现在主题 - + Open themes folder 打开主题文件夹 - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + 禁用 - + Menu settings 选单设定 - + Show keyboard shortcuts in right-click menus 在右击选单中显示键盘快速键 - + Show game filter toolbar above list in room tab - + Card rendering 卡面算绘 - + Display card names on cards having a picture 最有图片的牌上显示牌名 - + Auto-Rotate cards with sideways layout 将打横佈局卡片自动向上旋转 - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + 浅色 - + Dark - + 深色 - + + System + 跟随系统 + + + + Show all playmats - + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector 将牌库中包含的牌组推到艺术选择视窗之顶 - + Scale cards on mouse over 卡牌随游标缩放 - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand 在迭区中和直行手牌中,尽量减少交重迭百分点 - + Maximum initial height for card view window: 卡望框最高开头高度 - - + + rows 横列 - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 手牌佈局 - + Display hand horizontally (wastes space) 打栋显示手牌(浪费空间) - + Enable left justification 啟用向左对齐 - + Table grid layout 表格布局 - + Invert vertical coordinate 反转垂直坐标 - + Minimum player count for multi-column layout: 界面佈局之内能够容纳的最少玩家数量: - + Maximum font size for information displayed on cards: 卡牌上显示的最大字体大小 @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 禁用 &用家名字 - + ban &IP address 禁用&IP地址 - + ban client I&D 禁用客户ID - + Ban type 禁用类型 - + &permanent ban &永久禁用 - + &temporary ban &暂时禁用 - + &Days: &日 - + &Hours: &小时 - + &Minutes: &分鐘 - + Duration of the ban 禁用时间 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 请记载禁用原因 这项只保留为版主, 被禁用户不能看到. - + Please enter the reason for the ban that will be visible to the banned person. 请记载被禁用户能看到的被禁原因. - + Redact all messages from this user in all rooms 将这用户所有室中打出的讯息完全移除 - + &OK &OK - + &Cancel &取消 - + Ban user from server 把用户从本伺服器禁用 - - - - + + + + Error 出现错误 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 若要对用户实施禁用,请你选择以下叁种的拼合:名字,IP,或用户id. - + You must have a value in the name ban when selecting the name ban checkbox. 若用用户名字施行禁用,必须要填写姓名. - + You must have a value in the ip ban when selecting the ip ban checkbox. 若用ip地址施行禁用,必须要写下IP地址. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 若用用户ID施行禁用,必须要写下用户ID. @@ -499,22 +634,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta 测试版 - + No reply received from the release update server. 从发佈更新伺服器收不到回覆. - + Invalid reply received from the release update server. 从发佈更新伺服器收不到有效的回覆. - + No reply received from the file update server. 从档案更新伺服器收不到有效的回覆. @@ -522,7 +657,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + 模式 - + Reason @@ -553,65 +701,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name 名字 - + Sets 牌组 - + Mana cost 法力费用 - + Card type 卡牌类别 - + P/T 力量/防御力 - + Color(s) 顏色 + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -710,33 +1002,33 @@ This is only saved for moderators and cannot be seen by the banned person. Main Type - + 主要类型 Sub Type - + 子类型 CardInfoFrameWidget - + Image 卡图 - + Description 描述 - + Both 两个都显示 - + View transformation @@ -744,50 +1036,50 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards 查看关联卡牌 - + Add card to deck - + 添加卡牌到牌库 - + Mainboard - + 主牌库 - + Sideboard - + 备牌 CardInfoTextWidget - + Name: 名称: - + Set: - + Collector Number: - + Related cards: 有关牌名: - + Unknown card: 不知名的牌 @@ -795,129 +1087,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + 查看关联卡牌 - + Token: - + 衍生物: - + All tokens - - - - - &Select All - + 所有衍生物 + &Select All + &全选 + + + S&elect Row - + S&elect Column - + &Play - + &Hide - + &隐藏 - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -932,13 +1224,13 @@ This is only saved for moderators and cannot be seen by the banned person. Filesystem - + 文件系统 CardSizeWidget - + Card Size 卡牌大小 @@ -1078,6 +1370,24 @@ This is only saved for moderators and cannot be seen by the banned person. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1104,283 +1414,385 @@ This is only saved for moderators and cannot be seen by the banned person. Pick colour + 选择颜色 + + + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + 登入失败 - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + 出错 - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - - Server Full + + Password Change Required - + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + + Server Full + 服务器已满 + + + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + 注册被拒绝 - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + 密码太短。 - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers 更新预览卡牌 - + Success 成功 - + Download URLs have been reset. 下载URL已重置。 - + Add URL 添加URL - - + + URL: URL: - - + + Edit URL 更改URL - + Add New URL 添置新 URL - + Remove URL 移除 URL. - + Updating... 更新中... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path 选择路径 - + URL Download Priority URL下载优先次序 - + Spoilers 预览 - + Download Spoilers Automatically 自动下载预览 - + Spoiler Location: 预览位置: - + Last Change 最后变更 - + Spoilers download automatically on launch 开啟时自动下载预览 - + Press the button to manually update without relaunching 免重新啟动人手按制更新 - + Do not close settings until manual update is complete 人手更新完成前,不能关掉设置 - + Download card pictures on the fly 即时下载卡牌图片 - + How to add a custom URL 如何添加自定URL - + Reset Download URLs 重置下载URL @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count 数目 - + Set 牌组 - + Number 号码 - + Provider ID 提供者 id - + Card 卡牌 @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... 载入牌库... - + Load remote deck... 载入伺服器上的牌库... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 準备好 - + Force start 强迫开始 - + Sideboard unlocked 备牌解锁 - + Sideboard locked 解锁锁上 - - + + Error 出现错误 - + The selected file could not be loaded. 选择的档案不能再入 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. 下载中... - + Known Hosts 已知主办机 - + Delete the currently selected saved server 删除现选储藏伺服器. - + Refresh the server list with known public servers 从已知的伺服器更新伺服器列表 - + New Host 新主办 - + Name: 名称: - + &Host: &主办机: - + &Port: &端口: - + Player &name: 玩家 &名字: - + P&assword: &密码: - + &Save password &储存密码 - + A&uto connect &自动连接 - + Automatically connect to the most recent login when Cockatrice opens 当Cockatrice启动时自动连接到最近的登录 - + If you have any trouble connecting or registering then contact the server staff for help! 如在连接或註册谁中遇到问题,请联络伺服器职员寻求援助! - - + + Webpage 网页 - + Reset Password 重设密码 - + Forgot password? 密码忘记了? - + &Connect 连接 - + Server 伺服器 - + Login 登录 - + Server Contact 伺服器联络人 - + Connect to Server 连接伺服器 - + Server URL 伺服器URL - + Communication Port 通信端口 - + Unique Server Name 独特伺服器名字 - + Connection Warning 连接警告 - + You need to name your new connection profile. 你必须将你的连接简介命名 - + Connect Warning 连接警告 - + The player name can't be empty. 玩家名称不能空置 @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings &记住设定 - + &Description: &描述: - + P&layers: 玩家: - + General 一般 - + Game type 游戏类型 - + &Password: &密码: - + Only &buddies can join 只容许&好友加入 - + Only &registered users can join 只容许&註册用户加入 - + Joining restrictions 加入条件 - + &Spectators can watch 允许观看者 - + Spectators &need a password to watch 观看者&需要密码 - + Spectators can &chat 观看者可以&聊天 - + Spectators can see &hands 观看者可见&手牌 - + Create game as spectator 以旁观者的身份创建游戏 - + Spectators 观看者 - + Starting life total: 开始生命总数 - + Open decklists in lobby - + Create game as judge - + Game setup options 游戏设定选择 - + &Clear &清除 - + Create game 创建游戏 - + Game information 游戏资讯 - + Error 出现错误 - + Server error. 伺服器出现错误. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &名称: - + Token 令牌 - + C&olor: &顏色: - + white 白色 - + blue 蓝色 - + black 黑色 - + red 红色 - + green 绿色 - + multicolor 多色 - + colorless 无色 - + &P/T: &力量/防御力: - + &Annotation: &註释: - + &Destroy token when it leaves the table &此令牌离场时会被摧毁 - + Create face-down (Only hides name) - + Token data 令牌资料 - + Show &all tokens 显示&所有令牌 - + Show tokens from this &deck 显示本牌库所有令牌 - + Choose token from list 从清单中选择令牌 - + Create token 派出令牌 @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. 未选择图像 @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. 若要移除现有头像, 请不要选择新的图片,直接确认. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... 瀏览... - + Change avatar 更改头像 - + Open Image 打开图片 - + Image Files (*.png *.jpg *.bmp) 图片文件(*.png *.jpg *.bmp格式) - + Invalid image chosen. 所选图片不能使用。 @@ -2836,38 +3454,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: 旧密码: - + New password: 新密码: - + Confirm new password: 确认新密码: - + Change password 更改密码 - - + + Error 出现错误 - + Your password is too short. 你的密码太短。 - + The new passwords don't match. 新旧密码不一致 @@ -2875,93 +3493,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: &名称: - + C&olor: &顏色: - + white 白色 - + blue 蓝色 - + black 黑色 - + red 红色 - + green 绿色 - + multicolor 多色 - + colorless 无色 - + &P/T: &力量/防御力: - + &Annotation: &註释: - + Token data 令牌资料 - - + + Add token 添加令牌 - + Remove token 移除令牌 - + Edit custom tokens 编辑自定令牌 - + Please enter the name of the token: 请输入令牌名称: - + Error 出现错误 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 现时选择的名字跟现有的卡牌或令牌出现衝突.请从"牌组管理"中啟动'令牌牌组',保持正确显示. @@ -3039,7 +3657,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started + Hide started games + Hide games that have started @@ -3075,8 +3694,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - &创建者名称: + &Host name: + &Creator name: + @@ -3142,52 +3762,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning 重设密码验证警告 - + A problem has occurred. Please try to request a new password again. 程序出现故障,请再要求新的密码. - + Enter the information of the server and the account you'd like to request a new password for. 请输入需要新密码的伺服器和相关户口资料. - + &Host: &主办机: - + &Port: &端口: - + Player &name: 玩家 &名字: - + Email: 电邮: - + Reset Password Challenge 重设密码验证 - + Reset Password Challenge Error 重设密码验证错误 - + The email address can't be empty. 电邮不能空置. @@ -3195,37 +3815,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. 请输入需要新密码的伺服器资料. - + &Host: &主办机: - + &Port: &端口: - + Player &name: 玩家 &名字: - + Reset Password Request 重设密码要求 - + Reset Password Error 重设密码错误 - + The player name can't be empty. 玩家名称不能空置 @@ -3233,94 +3853,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning 重设密码警告 - + A problem has occurred. Please try to request a new password again. 程序出现故障,请再要求新的密码. - + Enter the received token and the new password in order to set your new password. 请输入收到验证码和新的密码,以便定立新的密码. - + &Host: &主办机: - + &Port: &端口: - + Player &name: 玩家 &名字: - + Token: 验证码 - - + + New Password: 新密码: - + Reset Password 重设密码 - + The player name can't be empty. 玩家名称不能空置 - - - - + + + + Reset Password Error 重设密码错误 - + The token can't be empty. 验证码不能为空白 - + The new password can't be empty. 新密码不能为空白 - + Error 出现错误 - + Your password is too short. 你的新密码太短。 - + The passwords do not match. 新旧密码不一致. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck 载入牌库 @@ -3404,78 +4057,258 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): 卡牌名称(或搜寻表达式) - + Number of hits: 因为cockatrice原生没有ssl支援, 所以你不能自动下载更新. 请瀏览到我们的下载网页,进行人手更新. - + Auto play hits 开关副牌上锁 - + Put top cards on stack until... 把顶牌放在重迭区, 直到... - + No cards matching the search expression exists in the card database. Proceed anyways? 在卡牌资料库中找不到符合搜寻表达式的卡牌.你想继续吗? - + Cockatrice Cockatrice - + Invalid filter 筛选无效 + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. 输入你, 和你想登记的伺服器的资料. @@ -3483,88 +4316,309 @@ Your email will be used to verify your account. 我们会使用你的电邮来核实你的户口. - + &Host: &主办机: - + &Port: &端口: - + Player &name: 玩家 &名字: - + P&assword: &密码: - + Password (again): (再次)输入密码: - + Email: 电邮: - + Email (again): (再次)输入电邮: - + Country: 国家: - + Undefined 未定义 - + Real name: 实名: - + Register to server 註册加入伺服器 - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning 注册警告 - + Your password is too short. 你的密码太短。 - + Your passwords do not match, please try again. 两次输入的密码不一致,请重新输入, - + Your email addresses do not match, please try again. 两次输入的电子邮箱不一致,请重新输入。 - + The player name can't be empty. 玩家名称不能空置 + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3630,46 +4684,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 读取卡牌资料库时出现不知名错误 - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - 你的卡牌资料库无效。 - -资料库无效可能会导致Cockatrice无法正确运行。 - -你应该重新启动Oracle,更新卡牌资料库。 - -你想要重新设置卡牌资料库路径吗? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - 你的卡牌资料库过旧。 - -这可能会导致卡牌图片或资料出现问题。 - -你应该重新启动Oracle,更新卡牌资料库。 - -你想要重新设置卡牌资料库路径吗? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3682,7 +4702,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + File Error loading your card database. Would you like to change your database location setting? @@ -3690,7 +4710,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3699,7 +4719,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3715,104 +4735,235 @@ https://github.com/Cockatrice/Cockatrice/issues 你想不想更改你的资料库位置设定? - - - + + + Error 出现错误 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 你的牌库目录路径现在无效. 你想要重新设置套牌路径吗? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 你的卡牌图片目录路径现在无效. 你想要重新设置卡牌图片资料库路径吗? - + Settings 设定 - + + Search settings... + + + + + OK + + + + General 一般 - + Appearance 外观 - + User Interface 用户界面 - + Storage - + Card Sources 卡牌来源 - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat 聊天 - + Sound 声音 - + Shortcuts 快捷键 + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3820,17 +4971,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next 下一个 - + Previous 上一个 - + Tip of the Day 每日提示 @@ -3838,165 +4989,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel 现在发行渠道 - + Reinstall 重新安装 - + Cancel Download 取消下载 - + Open Download Page 打开下载页面 - + Check for Client Updates 检查客户端更新 - - - - + + + + Error 出现错误 - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. 鸡蛇不支持建立SSL连接,因此你不能自动下载更新! 请访问下载页面以手动更新。 - + Downloading update: %1 - + Checking for updates... 正在检查更新... - + Finished checking for updates 更新检查已完成 - + No Update Available 没有更新可用 - + Cockatrice is up to date! Cockatrice 已经达到最新版本! - + You are already running the latest version available in the chosen release channel. 你运行的已经是最新的发行版本了。 - + Current version 现行版本 - + Selected release channel 现选发行渠道 - - + + Update Available 现有更新版本 - - + + A new version of Cockatrice is available! Cockatrice有新版本可用! - - + + New version 新版本 - - + + Released 已发行 - - + + Changelog 变更记载 - + Do you want to update now? 你打算现在更新吗? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error 更新出现错误 - + An error occurred while checking for updates: 检测更新时出现错误: - + An error occurred while downloading an update: 下载更新时出现错误 - + Installing... 安装中 - + Cockatrice is unable to open the installer. Cockatrice 不能啟动安装程式 - + Try to update manually by closing Cockatrice and running the installer. 请你尝试人手更新, 关掉cockatrice, 然后重开安装程式. - + Download location 下载位置 @@ -4004,17 +5165,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing 关闭时清除纪录 - + Copy to clipboard - + Debug Log 侦错纪录 @@ -4252,7 +5413,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4260,7 +5421,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here 输入你的筛选类型 @@ -4268,45 +5429,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4314,140 +5518,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 出现错误 - + Please join the appropriate room first. 请先参加合适房间 - + Wrong password. 密码错误. - + Spectators are not allowed in this game. 这个游戏不允许观眾加入. - + The game is already full. 这个游戏已经满了. - + The game does not exist any more. 这个游戏已经不存在了。 - + This game is only open to registered users. 这个游戏只开放给註册用户. - + This game is only open to its creator's buddies. 这个游戏只开放给房间主办的好友。 - + You are being ignored by the creator of this game. 你被这个房间的主办屏蔽了. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game 加入游戏 - - Password: - 密码: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. 请先参加相关的房间. - + &Filter games &筛选游戏 - + C&lear filter &清除筛选项目 - + C&reate &创建 - + &Join &加入 - + Join as judge - + J&oin as spectator &作为观看者加入 - + Join as judge spectator - + Games shown: %1 / %2 显示游戏:%1/%2 - + Games 游戏 @@ -4488,105 +5708,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1日 - + %1%2 hr short age in hours %1%2 小时 - + new 新建 - + %1%2 min short age in minutes %1%2 分鐘 - + password 密码 - + buddies only 仅限好友 - + reg. users only 仅限註册用户 - + open decklists - - + + can chat 允许聊天 - + see hands 查看手牌 - + can see hands 允许查看手牌 - + not allowed 不允许 - + Room 房间 - + Age 时长 - + Description 描述: - - Creator - 创建者 + + Host + - + Type 类型 - + Restrictions 限制 - + Players 玩家 - + Spectators 观看者 @@ -4594,158 +5814,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths 重置所有路径 - + All paths have been reset 重置所有路径 - - - - - - - + + + + English + 简体中文 (Chinese Simplified) + + + + + + + + + Choose path 选择路径 - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: 语言: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) 路径(在便携模式下不能编辑) - + Paths 路径 - + How to help with translations - + Decks directory: 牌库路径: - + Filters directory: - + Replays directory: 游戏录像目录: - + Pictures directory: 图片目录: - + Card database: 卡牌资料库: - + Custom database directory: 自主资料库目录 - + Token database: 令牌资料库: - + Update channel 更新渠道 - + Check for client updates on startup 啟动时检查客户更新 - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 如果客户端缺少伺服器支持的更功能时,请提示我 - + Automatically run Oracle when running a new version of Cockatrice 啟动新版本Cockatrice时,自动运行Oracle. - + Show tips on startup 显示启动时提示 - + Last update check on %1 (%2 days ago) @@ -4753,47 +6090,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4887,59 +6224,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5157,273 +6788,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 玩家 %1 - + Load replay 载入游戏录像 - + About Cockatrice 关於Cockatrice - + Version 版本 - + Cockatrice Webpage Cockatrice 网页 - + Project Manager: 项目经理: - + Past Project Managers: 前项目经理: - + Developers: 开发者: - + Our Developers 我们的开发者: - + Help Develop! 协助开发! - + Translators: 翻译者: - + Our Translators 翻译者名单 - + Help Translate! 协助翻译! - + Support: 支援: - + Report an Issue 报告问题 - + Troubleshooting 排除故障 - + F.A.Q. 问答 - - + + Error 出现错误 - + &Connect... &连线... - + &Disconnect &断线 - + Start &local game... 开始&离线游戏 - + &Watch replay... &观看游戏录像 - + &Full screen &全屏 - + &Register to server... &註册加入伺服器 - + &Restore password... &弄回密码 - + &Settings... 设置⋯ - + &Exit &退出 - + A&ctions &动作 - + &Cockatrice &Cockatrice - + C&ard Database &卡牌资料库 - + &Manage sets... &管理牌组 - + Edit custom &tokens... 编辑自定 &令牌... - + Open custom image folder 打开自定图片文件夹 - + Open custom sets folder 打开自定牌组文件夹 - + Add custom sets/cards 添加自定卡组/卡牌 - + Reload card database 刷新卡牌资料库 - + Tabs - + &Help &求助 - + &About Cockatrice &关於Cockatrice - + &Tip of the Day &每日提示 - + Check for Client Updates 检查客户端更新 - + Check for Card Updates... 检查卡牌更新 - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log 查看&侦错纪录 - + Open Settings Folder 打开设置文件夹 - + + Re-run Onboarding Wizard... + + + + Show/Hide 显示/隐藏 - + + Sign-in cancelled + + + + New Version 新版本 - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. 恭喜你将Cockatrice升级为%1. 现在会帮你啟动oracle, 更新你的卡牌资料库. - + Cockatrice installed Cockatrice 已被安装 - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. 恭喜你成功安装Cockatrice%1. 现在会帮你啟动oracle, 安装你的卡牌资料库. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database 卡牌资料库 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5432,29 +7084,29 @@ If unsure or first time user, choose "Yes" 如果不确定, 或者是第一次使用,请选择“是/Yes” - - + + Yes 是/Yes - - + + No 否/No - + Open settings 打开设置 - + New sets found 发现新卡组 - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5463,22 +7115,22 @@ Do you want to enable it/them? 要啟用它们吗? - + Yes, always enable - + View sets 查看卡组 - + Welcome 欢迎 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5487,88 +7139,88 @@ Read more about changing the set order or disabling specific sets and consequent 请阅读,了解更多在“卡组设置”对话框裡.更改卡组顺序或者禁用某些卡组的功能. - + Information 资讯 - + A card database update is already running. 资料库更新现已进行. - + Unable to run the card database updater: 不能运行卡牌资料附更新程式: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards 载入卡组/卡牌 - + Selected file cannot be found. 找不到选择的文件。 - + You can only import XML databases at this time. 你暂时只能导入xml资料库. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 已经成功添加新的卡组/卡牌. Cockatrice会重新载入卡牌资料库. - + Sets/cards failed to import. 卡组/卡牌导入失败. @@ -5773,615 +7425,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play 从战场上 - + from their graveyard 从它们的坟场 - + from exile 从放逐区 - + from their hand 从它们的手牌 - + the top card of %1's library %1牌库的顶牌 - + the top card of their library 它们的顶牌 - + from the top of %1's library 从%1牌库的顶 - + from the top of their library 从它们1牌库顶 - + the bottom card of %1's library %1牌库的底牌 - + the bottom card of their library 它们牌库的底牌 - + from the bottom of %1's library 从%1的牌库底 - + from the bottom of their library 从它们的牌库底 - + from %1's library 从%1的牌库 - + from their library 从它们的牌库 - + from sideboard 从备牌中 - + from the stack 从迭区中 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1现在保持%2顶牌显现. - + %1 is not revealing the top card %2 any longer. %1不再展示牌库顶牌%2。 - + %1 can now look at top card %2 at any time. %1可以随时查看牌库顶牌 %2. - + %1 no longer can look at top card %2 at any time. %1再不可以随时查看牌库顶牌 %2. - + %1 attaches %2 to %3's %4. %1 结附 %2 指向 %3的 %4. - + %1 has conceded the game. %1 在此游戏宣佈降服. - + %1 has unconceded the game. %1 收回在此游戏降服. - + %1 has restored connection to the game. %1已恢復连接。 - + %1 has lost connection to the game. %1 已失去连接。 - + %1 points from their %2 to themselves. %1 从它们的%2 指向自己。 - + %1 points from their %2 to %3. %1 从它们的%2指向%3。 - + %1 points from %2's %3 to themselves. %1 从%2的%3 指向自己。 - + %1 points from %2's %3 to %4. %1 从%2的%3 指向%4。 - + %1 points from their %2 to their %3. %1 从它们的%2指向它们的%3。 - + %1 points from their %2 to %3's %4. %1 从它们的%2指向%3的%4。 - + %1 points from %2's %3 to their own %4. %1 从%2的%3指向自己的%4。 - + %1 points from %2's %3 to %4's %5. %1 从%2的%3指向%4的%5。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1 派出令牌: %2%3 - + %1 has loaded a deck (%2). %1 已载入牌库(%2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1已载入含有 %2张备牌的牌库(%3). - + %1 destroys %2. %1销毁了%2。 - + a card 一张牌 - + %1 gives %2 control over %3. %1将%3的控制转移给%2. - + %1 puts %2 into play%3 face down. %1将%2%3牌面朝下地放进战场。 - + %1 puts %2 into play%3. %1将%2放进战场%3。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1将%2%3置入它们的的坟场。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 放逐%2%3. - + %1 moves %2%3 to their hand. %1将%2%3移到它们的的手牌中。 - + %1 puts %2%3 into their library. %1将%2%3放入它们的牌库。 - + %1 puts %2%3 onto the bottom of their library. %1将%2%3放在它们的牌库底部。 - + %1 puts %2%3 on top of their library. %1将%2%3放在它们的牌库顶部。 - + %1 puts %2%3 into their library %4 cards from the top. %1将%2%3放在它们的牌库顶部%4张牌以下。 - + %1 moves %2%3 to sideboard. %1 将%2%3移到副牌库. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1使出 %2%3。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1尝试从空牌库抽牌 - + %1 draws %2 card(s). %1抽%2张牌。 - + %1 is looking at %2. %1 正在查看%2。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 正在查看%2的 %4 %3张卡牌. - + bottom 底部 - + top 顶部 - + %1 turns %2 face-down. %1把%2翻为面朝下。 - + %1 turns %2 face-up. %1把%2翻为面朝上。 - + The game has been closed. 游戏已经关闭。 - + The game has started. 游戏已开始。 - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 已经加入游戏。 - + %1 is now watching the game. %1 正在旁观游戏。 - + You have been kicked out of the game. 你已被踢出游戏。 - + %1 has left the game (%2). %1 已经离开游戏(%2)。 - + %1 is not watching the game any more (%2). %1 已经不再旁观游戏(%2)。 - + %1 is not ready to start the game any more. %1 还未準备好开始游戏。 - + %1 shuffles their deck and draws a new hand of %2 card(s). %1将其牌库洗牌,然后抽%2张牌作为新的起手。 - + %1 shuffles their deck and draws a new hand. %1将其牌库洗牌,然后抽起新的手牌。 - + You are watching a replay of game #%1. 你正在观看游戏#%1的录像。 - + %1 is ready to start the game. %1已準备好开始游戏。 - + cards an unknown amount of cards 卡牌(眾)  - + %1 card(s) a card for singular, %1 cards for plural %1 卡牌(眾)  - + %1 lends %2 to %3. %1把&2借给%3. - + %1 reveals %2 to %3. %1把%2展示给%3. - + %1 reveals %2. %1 展示 %2。 - + %1 randomly reveals %2%3 to %4. %1随机展示%2%3给%4。 - + %1 randomly reveals %2%3. %1随机展示%2%3。 - + %1 peeks at face down card #%2. %1查看面朝下的卡牌#%2。 - + %1 peeks at face down card #%2: %3. %1查看面朝下的卡牌#%2:%3。 - + %1 reveals %2%3 to %4. %1展示%2%3给%4。 - + %1 reveals %2%3. %1展示%2%3。 - + %1 reversed turn order, now it's %2. %1 逆转回合次序,现在轮到%2。 - + reversed 逆转 - + normal 正常 - + Heads 正面 - + Tails 翻面 - + %1 flipped a coin. It landed as %2. %1掷了硬币。结果为%2。 - + %1 rolls a %2 with a %3-sided die. %1掷%3面骰子,结果为%2。 - + %1 flips %2 coins. There are %3 heads and %4 tails. %1掷了%2个硬币。结果为%3个正面和%4个反面。 - + %1 rolls a %2-sided dice %3 times: %4. %1把%2面骰子掷了%3次:%4 - + %1's turn. 轮到 %1. - + %1 sets annotation of %2 to %3. %1给%2添加了註释成%3。 - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 将%2指示物设置为 %3 (%4%5)。 - + %1 sets %2 to not untap normally. %1 将 %2 设置为不被重置。 - + %1 sets %2 to untap normally. %1 将 %2 设置为照常重置。 - + %1 removes the PT of %2. %1 移除%2的力量/防御. - + %1 changes the PT of %2 from nothing to %4. %1把%2的力量/防御从无数值转为%4. - + %1 changes the PT of %2 from %3 to %4. %1把%2的力量/防御从%3转为%4. - + %1 has locked their sideboard. %1已经锁定它们的的副牌库。 - + %1 has unlocked their sideboard. %1已经把它们的的副牌库解锁。 - + %1 taps their permanents. %1横置它们的永久卡牌 - + %1 untaps their permanents. %1重置它们的永久卡牌. - + %1 taps %2. %1 横置 %2. - + %1 untaps %2. %1 重置 %2。 - + %1 shuffles %2. % 1 对 %2 洗牌 - + %1 shuffles the bottom %3 cards of %2. %1 洗 %2 底的 %3 张卡牌. - + %1 shuffles the top %3 cards of %2. %1 洗 %2 顶的 %3 张卡牌. - + %1 shuffles cards %3 - %4 of %2. %1到%2.洗%3 - %4 - + %1 unattaches %2. %1 取消了%2的结附。 - + %1 undoes their last draw. %1 放回它们最后一张抽到的卡牌. - + %1 undoes their last draw (%2). %1 放回它们最后一张抽到的卡牌(%2). @@ -6389,115 +8041,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 词1 词2 词3 - + Add New Message 添加新信息 - + Edit Message 更改信息 - + Remove Message 移除信息 - + Add message 添加信息 - - + + Message: 信息: - + Edit message 更改信息 - + Chat settings 聊天设定 - + Custom alert words 自定通知触发词语 - + Enable chat mentions 允许聊天中提到某人 - + Enable mention completer 允许自动完成提名 - + In-game message macros 游戏内短信速键 - + How to use in-game message macros 如何使用游戏内短信速键 - + Ignore chat room messages sent by unregistered users 不要显示未註册用户的聊天消息。 - + Ignore private messages sent by unregistered users 不要显示未註册用户的私人消息. - + Ignore private messages sent by non-buddy users - - + + Invert text color 反转文本顏色 - + Enable desktop notifications for private messages 容许桌面提醒提及私人消息 - + Enable desktop notification for mentions 容许桌面提醒提及聊天提名 - + Enable room message history on join 加入聊天室时开启消息历史 - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (顏色为16进制) - + Separate words with a space, alphanumeric characters only 将单词以空格区分,仅支持字母. @@ -6505,42 +8167,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6619,148 +8281,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6883,30 +8531,21 @@ Cockatrice will now reload the card database. 回合完结部 - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - zhs - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7025,16 +8664,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7055,12 +8950,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7068,25 +8963,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference 喜好 - + Pin Printing 版本上别针 - + Unpin Printing 版本脱别针 - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards 显示关联卡牌 + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7117,28 +9052,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical 英文字母次序 - + Preference 喜好 - + Release Date 发行日期 - - + + Descending 由后到先 - + Ascending 由先倒后 @@ -7154,95 +9089,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services 服务 - + Hide %1 隐藏 %1 - + Hide Others 隐藏其他 - + Show All 全部展示 - + Preferences... 喜好... - + Quit %1 退出%1 - + About %1 关於 %1 @@ -7250,17 +9193,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatrice卡牌资料库 (*.xml) - + All files (*.*) 所有档案格式 (*.*) - + Cockatrice replays (*.cor) Cockatrice录像文件 (*.cor) @@ -7280,17 +9223,22 @@ This setting means you'll only see the default printing for each card, instead o 眾令牌 - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7301,7 +9249,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7312,100 +9260,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK/确认 - + Save 保存 - + Save All 全部保存 - + Open 打开 - + &Yes &是/Yes - + Yes to &All &全选是/Yes - + &No &否/No - + N&o to All &全选否/No - + Abort 退出 - + Retry 重试 - + Ignore 不理 - + Close 关闭 - + Cancel 取消 - + Discard 弃牌 - + Help 协助 - + Apply 应用 - + Reset 重设 - + Restore Defaults 恢復常用认值 @@ -7413,121 +9371,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name 名字 - + ID ID - + Upload time 上载时间 + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name 名字 - + Players 玩家(眾) - + Keep 选用 - + Time started 开始时间 - + Duration (sec) 维持(秒) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7569,37 +9595,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms 房间(眾)  - + Joi&n &加入 - + Room 房间 - + Description 描述 - + Permissions 许可 - + Players 玩家(眾) - + Games 游戏(眾) @@ -7607,7 +9633,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7615,32 +9641,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table 从列表中选择行动 - + Hit the key/combination of keys you want to set for this action 按下以此行动设置的单键或组键 - + Invalid key 设置的单键或组键无效 - + Shortcut already in use by: 捷键已被:&使用 - + Clear 清除 - + Restore default 恢復常用认值 @@ -7673,56 +9699,92 @@ Fine-tune individual colours in the grid afterwards. 发行日期 + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts 恢復所有常用快键 - + Do you really want to restore all default shortcuts? 确定要恢復所有常用快键吗? - + Clear all default shortcuts 清除所有常用快键 - + Do you really want to clear all shortcuts? 确认清除所有快键吗? - + Section: 部分: - + Action: 行动: - + Shortcut: 快键: - + + Shortcut editor + + + + How to set custom shortcuts 如何设置自定快键 - + Clear all shortcuts 清除所有快键 - + Search by shortcut name 按快键名称搜寻 @@ -7778,12 +9840,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7791,27 +9853,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 启用&声效 - + Current sounds theme: 现前声效主题: - + Test system sound engine 测试系统声效引擎 - + Sound settings 声效设置 - + Master volume 主音量 @@ -7819,48 +9881,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended 预览季节已结束 - + Deleting spoiler.xml. Please run Oracle 现在正删除spoiler.xml。请啟动Oracle - - + + Spoilers download failed 预览下载出错 - + No internet connection 没有网络连接 - + Error 出现错误 - + Spoilers already up to date 预览已是最新 - + No new spoilers added 没有添加新预览 - + Spoilers have been updated! 预览已被更新 - + Last change: 最后变更: @@ -7868,157 +9930,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default 常用 - + No reply received from the release update server. 从发佈更新伺服器收不到有效的回覆. - + Invalid reply received from the release update server. 从发佈更新伺服器收不到有效的回覆. - + No reply received from the tag update server. 未收到标籤更新伺服器的回应. - + Invalid reply received from the tag update server. 从标籤更新伺服器收不到有效的回覆. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8026,17 +10101,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8157,118 +10248,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8281,42 +10372,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8329,52 +10420,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info 卡牌资讯 - + Deck 牌库 - + Filters 筛选项目 - + &View &视图 - + Card Database - + Printing 印刷版本 - + Visible 可见 - + Floating 浮动 - + Reset layout 重置布局 - + Deck: %1 牌库:%1 @@ -8382,49 +10473,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - + 卡牌信息 - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8432,22 +10523,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8455,138 +10546,236 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 本地档案系统 - + Server deck storage 伺服器牌库储藏 - - + + Open in deck editor 在套牌编辑打开 - + Rename deck or folder - + Upload deck 上载牌库 - + Download deck 下载套牌 - - - - + + + + New folder 新文件夹 - - + + Delete 删除 - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder 打开牌库文件夹 - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error 出现错误 - + Rename failed - - + + + Invalid deck file 无效牌库档案 - + Enter deck name 输入牌库名称 - + This decklist does not have a name. Please enter a name: 本牌库列表未有名称.请输入名称: - + Unnamed deck 未命名牌库 - + Failed to upload deck to server 牌库上载於伺服器时失败 - + Delete local file 删除本地档案 - + Are you sure you want to delete the selected files? 你确定要删除被选档案(眾)吗 ? - + Delete remote decks 删除伺服器上的牌库 - + Are you sure you want to delete the selected decks? 你确定要剷除所有被选的牌库吗? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: 新建文件夹名称: - + Deck Storage @@ -8594,20 +10783,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8620,27 +11060,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8653,191 +11093,207 @@ Please enter a name: TabGame - - - + + + Replay 游戏录像重温 - - + + Game 游戏 - - + + Player List 玩家列表 - - + + + Invite + + + + + Card Info 卡牌资讯 - - + + Messages 信息 - - + + Replay Timeline 录像时间线 - + &Phases 阶段 - + &Game 游戏 - + Next &phase 下个&阶段 - + Next phase with &action 下个阶段&行动 - + Next &turn 下个&回合 - + Reverse turn order 逆转回合次序 - + &Remove all local arrows &移除所有自设箭头 - + Rotate View Cl&ockwise &顺时针旋转视角 - + Rotate View Co&unterclockwise &逆时针旋转视角 - + Game &information 游戏&资讯 - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede 撤销&投降 - - - + + + &Concede &投降 - + &Leave game &离开游戏 - + C&lose replay &关闭游戏录像 - + &Focus Chat &聚焦聊天 - + &Say: &説: - + Selected cards - + &View &视图 - + Visible 可见 - + Floating 浮动 - + Reset layout 重置布局 - + Concede 投降 - + Are you sure you want to concede this game? 你确定要宣佈投降吗? - + Unconcede 撤销投降 - + You have already conceded. Do you want to return to this game? 你已经宣佈投降,你想回到这场游戏吗? - + Leave game 离开游戏 - + Are you sure you want to leave this game? 你确定要离开这个游戏吗? - + A player has joined game #%1 有玩家加入游戏 #%1 - + %1 has joined the game %1 已经加入游戏。 - + You have been kicked out of the game. 你已被踢出游戏。 @@ -8853,158 +11309,158 @@ Please enter a name: TabLog - + Logs 记录 - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName 时间;发送者姓名;发送者IP;信息;目标ID;目标姓名 - + Room Logs 房间记录 - + Game Logs 游戏记录 - + Chat Logs 聊天记录 - - + + Error 出现错误 - + You must select at least one filter. 您必须选择至少一个过滤项. - + You have to select a valid number of days to locate. 您必须选择一个可用的日数来作为定位. - + Username: 用户名称: - + IP Address: IP地址 - + Game Name: 游戏名称: - + GameID: 游戏ID: - + Message: 信息: - + Main Room 主房 - + Game Room 游戏房间 - + Private Chat 私人聊天 - + Past X Days: 过去 X 天: - + Today 今天 - + Last Hour 1小时前 - + Maximum Results: 最多结果: - + At least one filter is required. The more information you put in, the more specific your results will be. 至少需要1个筛选项。 您输入的信息越多,找到结果越準确。 - + Get User Logs 获取用户记录 - + Clear Filters 清除筛选项目 - + Filters 筛选项目 - + Log Locations 记录位置 - + Date Range 日期范围 - + Maximum Results 最多结果 - - + + Message History 信息历史 - + Failed to collect message history information. 不能获取信息历史消息 - + There are no messages for the selected filters. 没有符合筛选条件的信息。 @@ -9012,218 +11468,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat 私人&聊天 - + &Leave &离开 - + %1 - Private chat %1 - 私人聊天 - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. 你已被这用户屏蔽, 它们不能再主聊天室看见你的信息,你亦不能参加它们的游戏. - + Private message from 私人信息信到, 来自 - + %1 has left the server. %1 已离开伺服器. - + %1 has joined the server. %1 已加入伺服器. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system 本地档案系统 - + Server replay storage 伺服器录像仓库 - - + + Watch replay 观看游戏录像 - + Rename 改名 - - + + New folder 新文件夹 - - + + Delete 删除 - + Open replays folder 打开录像文件夹 - + Download replay 下载游戏录像 - + Toggle expiration lock 过期锁定 - + Get replay share code - - + + Look up replay by share code - + Rename local folder 本地档案夹改名 - + Rename local file 本地档案改名 - + New name: 新名称 - + Error 出错 - + Rename failed 改名不成功 - + Name of new folder: 新建文件夹名称: - + Delete local file 删除本地档案 - + Are you sure you want to delete the selected files? 你确定要剷除所有被选的档案吗? - + Are you sure you want to delete the selected replays? 你确定要删除被选录像档案吗 ? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 删除伺服器录像 @@ -9233,65 +12029,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &説: - + Chat 聊天 - + &Room &房间 - + &Leave room &离开房间 - + &Clear chat &清除聊天记录 - + Chat Settings... 聊天设置... - + mentioned you. 提到了你。 - + Click to view 点击查看 - + You are flooding the chat. Please wait a couple of seconds. 你正在刷屏。请稍等几秒鐘。 @@ -9299,35 +12508,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server 伺服器 - - - - + + + + Error 出现错误 - + Failed to join the server room: it doesn't exist on the server. 加入伺服器房间失败:在本伺服器上这房间并不存在。 - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. 伺服器认为你在房间内, 但Cockatrice无法显示。请重新启动Cockatrice。 - + You do not have the required permission to join this server room. 你未被允许进入该房间。 - + Failed to join the server room due to an unknown error: %1. 因为以下不知名的错误,所以不能参入伺服器房间: %1. @@ -9335,97 +12544,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? 你确定吗? - + There are still open games. Are you sure you want to quit? 游戏还在继续, 你确定要退出吗? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view 点击查看 - + Your buddy %1 has signed on! 你的好友%1已登录! - + Unknown Event 不知名事件 - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9436,38 +12721,38 @@ To update your client, go to Help -> Check for Updates. 要升级你的客户端,请点击“帮助”->“检查更新” - + Idle Timeout 閒置超时 - + You are about to be logged out due to inactivity. 你即将因为长时间没有活动而被退出。 - + Promotion 升级 - + You have been promoted. Please log out and back in for changes to take effect. 你已被升级.请登出然后再登入让本更改生效. - + Warned 被警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 你由於$1 原因,现在接受警告. 请避免继续本行为, 否则你将有可能会接受进一步的警诫或惩罚. 若有任何疑问, 请私信我们门下的版主. - + You have received the following message from the server. (custom messages like these could be untranslated) 你收到松本伺服器发出的以下讯息(类似本信息的自定讯息可能未经翻译) @@ -9486,6 +12771,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9500,6 +12813,65 @@ Please refrain from engaging in this activity or further actions may be taken ag 不能分析本牌库. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9571,57 +12943,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9629,216 +12996,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details 用户&详细信息 - + Private &chat 私人&聊天 - + Show this user's &games 显示这个用户的&游戏 - + + View this user's &public decks + + + + Add to &buddy list 加入到&好友列表 - + Remove from &buddy list 从&好友列表移除 - + Add to &ignore list 加入到&屏蔽列表 - + Remove from &ignore list 从&屏蔽列表移除 - + Kick from &game 从&游戏中踢出 - + + Report user + + + + Warn user 警告用户 - + View user's war&n history 查看用户警告记录 - + Ban from &server 把用户从&伺服器禁用 - + View user's &ban history 查看用户&禁止记录 - + &Promote user to moderator 升$用户作为版主 - + Dem&ote user from moderator 把&用户从版主降级 - + Promote user to &judge 把&用户升级成裁判 - + Demote user from judge 把&用户从裁判降级 - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes 阅读管理笔记 - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games %1 的游戏(眾) - - - + + + Ban History 禁用历史 - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason 禁止时间;版主;禁止时长;禁止理由;可见理由 - + User has never been banned. 用户从未被禁 - + Failed to collect ban information. 不能获取禁用讯息 - - - + + + Warning History 警告历史 - + Warning Time;Moderator;User Name;Reason 警告时间;版主;用户名称;理由 - + User has never been warned. 用户从未接收警告 - + Failed to collect warning information. 不能获取警告信息 - + Failed to get admin notes. 不能获取管理笔记 - - + + Success 成功 - + Successfully promoted user. 用户升级成功 - + Successfully demoted user. 用户降级成功 - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed 失败 - + Failed to promote user. 用户升级失败 - + Failed to demote user. 用户降级失败 - + Copy hash to clipboard 把哈希覆印到用户剪贴 - + Remove this user's messages 移除这用户讯息 @@ -9876,61 +13293,61 @@ Please refrain from engaging in this activity or further actions may be taken ag 管理员 - + Moderator 版主 - + Registered user 登记用户 - - + + Unregistered user 未登记用户 - + Judge 裁判 - + Unknown 不知名 - + The entered password does not match your account. 输入密码跟你户口不匹配 - - - - + + + + Information 资讯 - + User information updated. 用户资讯已更新。 - - - - - - + + + + + - - - + + + + Error 出现错误 @@ -9954,283 +13371,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n年(眾) , - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n日(眾) %20 - + Enter Password 输入密码 - + Password verification is required in order to change your email address 要核实密码才能更改你的电邮地址 - - - + + + An error occurred while trying to update your user information. 在尝试更新你的用户信息时出现错误. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. 本伺服器不容许你更新你的用户资讯. - + Password changed. 密码已更改 - + This server does not permit you to change your password. 本伺服器不容许你更改密码 - + The new password is too short. 新的密码太短 - + The old password is incorrect. 旧的密码不正确 - + Avatar updated. 头像已更新。 - + This server does not permit you to update your avatar. 本伺服器不容许你更新你的头像. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - 本伺服器不容许你更新你的用户资讯. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10238,223 +13664,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings 一般通用接口设置 - + &Double-click cards to play them (instead of single-click) 双点击使出卡牌 (而不是单点击使出) - + &Clicking plays all selected cards (instead of just the clicked card) &单一点击把所有选择的卡牌使出(不只是被点击的单张卡牌) - + &Play all nonlands onto the stack (not the battlefield) by default &把所有使出的非地牌放入堆迭区(不是战场) 作为常用设置 - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 在令牌上标註牌中文字 - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen 使用浮动菜单,允许右键单击, 把菜单保留在屏幕上 - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings 通告设置 - + Enable notifications in taskbar 开启任务栏通告 - + Notify in the taskbar for game events while you are spectating 观看时在任务栏提示游戏信息 - + Notify in the taskbar when users in your buddy list connect 在任务栏中提示好友连到伺服器 - + Animation settings 动画设定 - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &横置/重置 动画 - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default 把用新页开啟牌库作为常用设置 - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings 重播设置 - + Buffer time for backwards skip via shortcut: 用速键啟动向后跳的缓衝时间: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10491,22 +14038,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10514,72 +14061,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10681,7 +14228,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10689,19 +14236,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10747,28 +14294,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10776,43 +14323,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10828,12 +14375,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10841,17 +14388,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10859,7 +14406,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10867,52 +14414,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10920,7 +14472,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10928,22 +14480,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10951,17 +14503,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10969,177 +14526,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 你想发出那种警告? - + Redact all messages from this user in all rooms 把这用户在所有房间留下的讯息删除 - + &OK &确认 - + &Cancel &取消 - + Warn user for misconduct 对行为不当的用户发出警告 - - + + Error 出现错误 - + User name to send a warning to can not be blank, please specify a user to warn. 接收警告的用户名称不能空白.请定好要接收警告的用户名字. - + Warning to use can not be blank, please select a valid warning to send. 警告名称不能留空. 请选择有效的警告. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top 将选择的牌组送到最顶 - + Move selected set up 将选择的牌组往上一格 - + Move selected set down 将选择的牌组往下一格 - + Move selected set to the bottom 将选择的牌组送到最底 - - Search by set name, code, or type - 用排组名称,代码,或类型进行搜寻 - - - + Default order 常用次序 - + Restore original art priority order 恢復常用艺术先后次序 - + Enable all sets 啟用所有牌组 - + Disable all sets 禁用所有卡组 - + Enable selected set(s) 啟用选择的牌组(眾)  - + Disable selected set(s) 停用选择的牌组(眾) - + Deck Editor 牌库编辑 - + Use CTRL+A to select all sets in the view. 请按 CTRL + A, 以此选择所有看到的牌组 - + Only cards in enabled sets will appear in the card list of the deck editor. 只有已经被啟用的卡牌组中的卡牌,才会在牌库编辑中的卡牌清单出现 - + Image priority is decided in the following order: 卡牌图片先后次序由以下次序决定: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki 自订档案夹(%`1)优先, 此对话窗中已被啟用的牌组为后(由顶到底) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 卡牌艺术 - + + Search by set name, code, type, or release date + + + + How to use custom card art 如何使用自定卡牌图片 - + Hints 提示 - + Note 笔记 - + Sorting by column allows you to find a set while not changing set priority. 按列排序允许您在不更改系列优先级别的情况下查找牌组。 - + To enable ordering again, click the column header until this message disappears. 要再次启用排序,请单击列标题,直到此消息消失。 - + Use the current sorting as the set priority instead 用现在排序代替牌组先后 - + Sorts the set priority using the same column 用同一列分牌组先后 - + Manage sets 管理牌组 @@ -11147,93 +14729,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped 未分组 - + Group by Type 按类型分组 - + Group by Mana Value 按法力数值分组 - + Group by Color 按顏色排序 - + Unsorted 未排序 - + Sort by Name 按名称排次序 - + Sort by Type 按类型排序 - + Sort by Mana Cost 按法力费用排序 - + Sort by Colors 按顏色(眾) 排序 - + Sort by P/T 按力量/防御力排序 - + Sort by Set 按排组排序 - + shuffle when closing 关闭界面时洗牌 - + pile view 栋迭观看 - - i18n - - - English - 简体中文 (Chinese Simplified) - - main - + Connect on startup 啟动时自动连接 - + Debug to file 侦错纪录写进档案 @@ -12017,6 +15591,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard 坟场 + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_zh-Hans.ts b/oracle/translations/oracle_zh-Hans.ts index 75ebdc138..29f603373 100644 --- a/oracle/translations/oracle_zh-Hans.ts +++ b/oracle/translations/oracle_zh-Hans.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction 介绍 - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. 此向导将会导入Cockatrice中用到的系列,卡牌和衍生物列表. - + Interface language: 界面语言: - + Version: 版本: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection 选择来源 - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. 请选择合适的牌组和卡片清单来源。您可以输入下载URL或使用电脑中已有的档案。 - + Download URL: 下载URL: - + Local file: 本地文件: - + Restore default URL 恢复常用URL - + Choose file... 选择档案... - + Load sets file 载入牌组档案 - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error 出现错误 - + The provided URL is not valid. 所提供的url无效. - - Downloading (0MB) - 下载中(0MB) - - - + Please choose a file. 请选择档案. - + Cannot open file '%1'. 不能开启档案 '%1 - - Downloading (%1MB) - 下载中(%1MB) - - - + Network error: %1. 网路出错: %1. - + Parsing file 分析档案中 - + Xz extraction failed. xz解压缩失败。 - + Sorry, this version of Oracle does not support xz compressed files. 很抱歉,现前Oracle版本不支援xc压缩档案 - + Failed to open Zip archive: %1. 未能开启压缩文件: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. 解压缩失败:这压缩文件拥有超过一个档案. - + Zip extraction failed: %1. 解压缩失败:%1。 - + Sorry, this version of Oracle does not support zipped files. 很抱歉,现前Oracle版本不支援压缩档案. - + Failed to interpret downloaded data. 不能分析下载资料. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? 你是否想另再下载未经压缩的档案? - + + The file was retrieved successfully, but it does not contain any sets data. 虽然档案成功取回, 但是档案并未含有任何牌组资料. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database 储存预览资料库 - + XML; spoiler database (*.xml) XML ; 预览资料库(*.XML) - + spoiler - + 预览 - + Spoilers import 导入预览卡牌 - + Please specify a compatible source for spoiler data. 请指定预览资料合用来源. - + Download URL: 下载URL: - + Local file: - + 本地文件: - + Restore default URL 恢复常用URL - + Choose file... - + 选择文件... - + The spoiler database will be saved at the following location: 预览资料库将会储藏于以下位置: - + Save to a custom path (not recommended) 保存到自定义路径(不推荐) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database 储存衍生物资料库 - + XML; token database (*.xml) XML; 衍生物资料库 (*.xml) - + tokens - + 衍生物 - + Tokens import 导入衍生物 - + Please specify a compatible source for token data. 请指定衍生物资料合用来源. - + Download URL: 下载URL: - + Local file: - + 本地文件: - + Restore default URL 恢复常用URL - + Choose file... - + 选择文件... - + The token database will be saved at the following location: 衍生物资料库将会储藏于以下位置: - + Save to a custom path (not recommended) 保存到自定义路径(不推荐) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens 包含衍生物的虚拟牌组 @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle导入器 @@ -292,22 +306,22 @@ OutroPage - + Finished 完成 - + The wizard has finished. 向导程式工作完成. - + You can now start using Cockatrice with the newly updated cards. Cockatrice已载入更新了卡牌, 现在已经可以使用. - + If the card databases don't reload automatically, restart the Cockatrice client. 如果卡牌资料库尚未能自动更新,请把cockatrice用户端重新开启. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error 出现错误 - + + No set has been imported. 没有牌组被导入。 - + Sets imported 已导入牌组 - - A cockatrice database file of %1 MB has been downloaded. - 有%1MB大小的Cockatrice资料库档案已被下载. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: 已找到以下牌组: - + Press "Save" to store the imported cards in the Cockatrice database. 要将导入卡牌保存于Cockatrice资料库内请按"储藏". - + The card database will be saved at the following location: 卡牌资料库将被储藏于以下位置 - + Save to a custom path (not recommended) 保存到自定义路径(不推荐) - + &Save &储存 - + + Import finished: %1 cards. 导入过程完成:成功导入%1张卡牌. - + + %1: %2 cards imported %1:成功导入%2张卡牌. - + Save card database 储存卡牌资料库 - + XML; card database (*.xml) XML; 卡牌资料库 (*.xml) - + The file could not be saved to %1 档案未能储藏于%1。 @@ -391,12 +408,12 @@ Load %1 file - + 载入 %1 文件 %1 file (%1) - + %1 文件 (%1) @@ -410,22 +427,23 @@ The provided URL is not valid: - + 所提供的url无效: - Downloading (0MB) - 下载中(0MB) + Downloading (0 MB) + Downloading (0MB) + Please choose a file. - + 请选择文件。 Cannot open file '%1'. - + 无法打开文件“%1”。 @@ -587,7 +605,7 @@ i18n - + English 简体中文 (Chinese Simplified) @@ -595,14 +613,14 @@ main - + Only run in spoiler mode 限于在预览模式运行 - + Run in no-confirm background mode - + 在“无确认背景”模式下运行 \ No newline at end of file From c5b24e81da19de4edb7603c014f0617b7267af72 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:21:03 +0200 Subject: [PATCH 053/247] Updates for project Cockatrice and language fi (#7549) * Translate oracle_en@source.ts in fi [Manual Sync] 82% of minimum 5% translated source file: 'oracle_en@source.ts' on 'fi'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in fi [Manual Sync] 33% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'fi'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_fi.ts | 7446 ++++++++++++++++------ oracle/translations/oracle_fi.ts | 204 +- 2 files changed, 5626 insertions(+), 2024 deletions(-) diff --git a/cockatrice/translations/cockatrice_fi.ts b/cockatrice/translations/cockatrice_fi.ts index c317d967a..f5167488e 100644 --- a/cockatrice/translations/cockatrice_fi.ts +++ b/cockatrice/translations/cockatrice_fi.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Lisää countteri... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Lisää countteri - + New value for counter '%1': Countterin uusi arvo '%1': @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings Teeman asetukset - + Current theme: Nykyinen teema: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Kortin renderöinti - + Display card names on cards having a picture Näytä kortin nimi korteissa, joissa on kuva - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Suurenna kortti kun hiiri on kortin päällä - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Käden layout - + Display hand horizontally (wastes space) Näytä käsikortit vierekkäin vaakatasossa (vie enemmän tilaa) - + Enable left justification Järjestä vasemmalta - + Table grid layout Pöydän grid-asetelma - + Invert vertical coordinate Käänteiset pystykoordinaatit - + Minimum player count for multi-column layout: Pelaajien vähimmäismäärä moniosaisessa layoutissa: - + Maximum font size for information displayed on cards: Suurin fonttikoko korteissa näkyville infoteksteille: @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Bännää &käyttäjä - + ban &IP address Bännää &IP osoite - + ban client I&D Bännää client I&D - + Ban type Bännäyksen tyyppi - + &permanent ban Bännätty pysyvästi - + &temporary ban Bännätty väliaikaisesti - + &Days: Päivät: - + &Hours: Tunnit: - + &Minutes: Minuutit: - + Duration of the ban Bännin kesto - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Kerro bännäyksen syy. Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. - + Please enter the reason for the ban that will be visible to the banned person. Kerro bännäyksen syy. Tämä näkyy bännätylle henkilölle. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Peruuta - + Ban user from server Bännää pelaaja serveriltä - - - - + + + + Error Virhe - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Valitse nimi, IP -osoite, clientid tai joku näiden yhdistelmä asettaaksesi bännin. - + You must have a value in the name ban when selecting the name ban checkbox. Bännää nimi kentässä pitää olla arvo ennen valintaruudun valitsemista - + You must have a value in the ip ban when selecting the ip ban checkbox. Bännää ip kentässä pitää olla arvo ennen valintaruudun valitsemista. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Bännää ClientID kentässä pitää olla arvo ennen valintaruudun valitsemista. @@ -499,22 +634,22 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. BetaReleaseChannel - + Beta - + No reply received from the release update server. Ei vastausta päivitysserveriltä. - + Invalid reply received from the release update server. Päivitysserveri vastasi virheellisesti - + No reply received from the file update server. Ei vastausta tiedoston päivitysserveriltä. @@ -522,7 +657,20 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardDatabaseModel - + Name Nimi - + Sets Setit - + Mana cost Mana cost - + Card type Korttityyppi - + P/T P/T - + Color(s) Väri(t) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -744,22 +1036,22 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -795,129 +1087,129 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardSizeWidget - + Card Size @@ -1078,6 +1370,24 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Päivitä spoilerit - + Success Onnistui - + Download URLs have been reset. Latauksen URL:t on resetoitu - + Add URL Lisää URL - - + + URL: URL: - - + + Edit URL Muokkaa URL - + Add New URL - + Remove URL - + Updating... Päivitetään... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Valitse polku - + URL Download Priority URL Latausprioriteetti - + Spoilers Spoilerit - + Download Spoilers Automatically Lataa spoilerit automaattisesti - + Spoiler Location: Spoilerien sijainti: - + Last Change Muokattu viimeksi - + Spoilers download automatically on launch Lataa spoilerit latauksen yhteydessä - + Press the button to manually update without relaunching Lataa spoilerit manuaalisesti ilman uudelleenkäynnistystä - + Do not close settings until manual update is complete Älä sulje asetuksia, ennen kuin manuaalinen päivitys on valmis - + Download card pictures on the fly Lataa korttikuvat kun niitä tarvitaan - + How to add a custom URL Miten lisään oman URL:n - + Reset Download URLs Resetoi latausten URL:t @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number Numero - + Provider ID - + Card Kortti @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Lataa pakka... - + Load remote deck... Lataa remote pakka... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start Ready to start - + Force start - + Sideboard unlocked Sideboard unlocked - + Sideboard locked Sideboard locked - - + + Error Virhe - + The selected file could not be loaded. Valittua tiedostoa ei voi avata. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. Ladataan... - + Known Hosts Tunnetut Isännät - + Delete the currently selected saved server - + Refresh the server list with known public servers Päivitä servereiden lista tunnetuilla julkisilla servereillä - + New Host Lisää Isäntä - + Name: Nimi: - + &Host: &Host: - + &Port: &Portti: - + Player &name: Pelaajan &nimi: - + P&assword: &Salasana: - + &Save password &Tallenna salasana - + A&uto connect Yhdistä &Automaattisesti - + Automatically connect to the most recent login when Cockatrice opens Kirjaudu sisään automaattisesti kun Cockatrice käynnistyy - + If you have any trouble connecting or registering then contact the server staff for help! Jos sinulla on ongelmia yhdistämisessä tai rekisteröinnissä ota yhteyttä serverin henkilökuntaan! - - + + Webpage Websivu - + Reset Password - + Forgot password? - + &Connect &Yhdistä - + Server Serveri - + Login Kirjaudu sisään - + Server Contact Serverin yhteystiedot - + Connect to Server Yhdistä serveriin - + Server URL Serverin URL - + Communication Port Tietoliikenneportti - + Unique Server Name Uniikki serverin nimi - + Connection Warning Yhteysvaroitus - + You need to name your new connection profile. Anna nimi uudelle yhteysprofiilillesi - + Connect Warning Yhdistämisvaroitus - + The player name can't be empty. Pelaajan nimi ei voi olla tyhjä. @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings &Muista asetukset - + &Description: &Kuvaus: - + P&layers: &Pelaajat: - + General Yleinen - + Game type Pelimuoto - + &Password: &Salasana: - + Only &buddies can join Vain &Kaverit voivat liittyä - + Only &registered users can join Vain &Rekisteröityneet käyttäjät voivat liittyä - + Joining restrictions Liittymisehdot - + &Spectators can watch &Salli katsojat - + Spectators &need a password to watch Katsojilta &vaaditaan salasana - + Spectators can &chat Katsojat voivat &keskustella - + Spectators can see &hands Katsojat &näkevät käsikortit - + Create game as spectator - + Spectators Katsojat - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear &Tyhjennä - + Create game Luo peli - + Game information Tietoa pelistä - + Error Virhe - + Server error. Serverivirhe. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nimi: - + Token Tokeni - + C&olor: &Väri - + white valkoinen - + blue sininen - + black musta - + red punainen - + green vihreä - + multicolor monivärinen - + colorless väritön - + &P/T: P/T: - + &Annotation: &Kommentti: - + &Destroy token when it leaves the table &Tuhoa tokeni kun se lähtee pöydästä - + Create face-down (Only hides name) - + Token data Tokenin data - + Show &all tokens Näytä &kaikki tokenit - + Show tokens from this &deck Näytä kaikki tämän &pakan tokenit - + Choose token from list Valitse tokeni listasta - + Create token Luo token @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Ei valittua kuvaa. @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. Poistaaksesi nykyisen avatarisi: Hyväksy valitsematta uutta kuvaa. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Selaa... - + Change avatar Vaihda avatar - + Open Image Avaa Kuva - + Image Files (*.png *.jpg *.bmp) Kuvatiedostot (*.png *.jpg *.bmp) - + Invalid image chosen. Kuva ei kelpaa. @@ -2836,38 +3454,38 @@ Poistaaksesi nykyisen avatarisi: Hyväksy valitsematta uutta kuvaa. DlgEditPassword - + Old password: Vanha salasana: - + New password: Uusi salasana: - + Confirm new password: Vahvista uusi salasana: - + Change password Vaihda salasana - - + + Error Virhe - + Your password is too short. - + The new passwords don't match. Uusi salasana ei täsmää @@ -2875,93 +3493,93 @@ Poistaaksesi nykyisen avatarisi: Hyväksy valitsematta uutta kuvaa. DlgEditTokens - + &Name: &Nimi: - + C&olor: &Väri - + white valkoinen - + blue sininen - + black musta - + red punainen - + green vihreä - + multicolor monivärinen - + colorless väritön - + &P/T: P/T: - + &Annotation: &Kommentti: - + Token data Tokenin data - - + + Add token Lisää token - + Remove token Poista token - + Edit custom tokens Muokkaa tokeneita - + Please enter the name of the token: Anna tokenin nimi: - + Error Virhe - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Nimi on ristiriidassa olemassaolevan kortin tai tokenin kanssa. @@ -3040,7 +3658,8 @@ Varmista että 'Token' setti on asetettu näkyväksi 'Muokkaa set - Hide games that have started + Hide started games + Hide games that have started @@ -3076,8 +3695,9 @@ Varmista että 'Token' setti on asetettu näkyväksi 'Muokkaa set - &Creator name: - &Tekijän nimi: + &Host name: + &Creator name: + @@ -3143,52 +3763,52 @@ Varmista että 'Token' setti on asetettu näkyväksi 'Muokkaa set DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: &Host: - + &Port: &Portti: - + Player &name: Pelaajan &nimi: - + Email: Email: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. Email -osoite ei voi olla tyhjä @@ -3196,37 +3816,37 @@ Varmista että 'Token' setti on asetettu näkyväksi 'Muokkaa set DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: &Host: - + &Port: &Portti: - + Player &name: Pelaajan &nimi: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. Pelaajan nimi ei voi olla tyhjä. @@ -3234,94 +3854,127 @@ Varmista että 'Token' setti on asetettu näkyväksi 'Muokkaa set DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: &Host: - + &Port: &Portti: - + Player &name: Pelaajan &nimi: - + Token: Tokeni - - + + New Password: Uusi Salasana: - + Reset Password - + The player name can't be empty. Pelaajan nimi ei voi olla tyhjä. - - - - + + + + Reset Password Error - + The token can't be empty. Tokeni ei voi olla tyhjä - + The new password can't be empty. Uusi salasana ei voi olla tyhjä - + Error - + Your password is too short. - + The passwords do not match. Salasanat eivät täsmää + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3405,165 +4058,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: &Host: - + &Port: &Portti: - + Player &name: Pelaajan &nimi: - + P&assword: &Salasana: - + Password (again): &Salasana (uudelleen): - + Email: Email: - + Email (again): Email (uudelleen): - + Country: Maa: - + Undefined Ei määritelty - + Real name: Oikea nimi: - + Register to server Rekisteröidy serverille - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Rekisteröintivaroitus - + Your password is too short. - + Your passwords do not match, please try again. Salasanat eivät täsmää, yritä uudelleen - + Your email addresses do not match, please try again. Sähköpostiosoitteet eivät täsmää, yritä uudelleen. - + The player name can't be empty. Pelaajan nimi ei voi olla tyhjä. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3629,46 +4683,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Tuntematon virhe ladattaessa korttitietokantaa - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Korttitietokantasi on virheellinen - -Cockatrice ei välttämättä toimi kunnolla virheellisellä tietokannalla - -Voit joutua ajamaan Oraclen ja päivittämään korttitietokannan uudelleen - -Haluatko vaihtaa tietokantasi sijaintia? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Tietokantasi versio on liian vanha - -Tästä voi seurata ongelmia korttien tietoja tai kuvia ladattaessa - -Yleensä tämä voidaan korjata palaamalla oracleen päivittämään korttitietokanta. - -Haluatko vaihtaa tietokantasi sijaintia? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3677,7 +4697,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3686,7 +4706,7 @@ Would you like to change your database location setting? Haluatko vaihtaa tietokantasi sijaintia? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3695,7 +4715,7 @@ Would you like to change your database location setting? Haluatko vaihtaa tietokantasi sijaintia? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3704,104 +4724,235 @@ Would you like to change your database location setting? - - - + + + Error Virhe - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Pakkojen tiedostosijainti on väärä. Haluatko palata takaisin ja asettaa oikean sijainnin pakat -kansiolle? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Kuvien tiedostosijainti on väärä. Haluatko palata takaisin ja asettaa oikean sijainnin kuvat -kansiolle? - + Settings Asetukset - + + Search settings... + + + + + OK + + + + General Yleinen - + Appearance Ulkonäkö - + User Interface Käyttöliittymä - + Storage - + Card Sources Korttien lähteet - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Äänet - + Shortcuts Pikanäppäimet + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3809,17 +4960,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Seuraava - + Previous Edellinen - + Tip of the Day Päivän vinkki @@ -3827,165 +4978,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel Tämänhetkinen julkaisukanava - + Reinstall Asenna uudelleen - + Cancel Download Peruuta Lataus - + Open Download Page Avaa Lataussivu - + Check for Client Updates - - - - + + + + Error Virhe - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatricessä ei ole SSL tukea, joten päivityksiä ei voi ladata automaattisesti! Vieraile lataussivulla päivittääksesi ohjelman manuaalisesti. - + Downloading update: %1 - + Checking for updates... Etsitään päivityksiä... - + Finished checking for updates Päivitysten haku on päättynyt - + No Update Available Ei uusia päivityksiä - + Cockatrice is up to date! Cockatrice on ajan tasalla! - + You are already running the latest version available in the chosen release channel. Sinulla on käytössä viimeisin versio, joka on saatavilla valitussa julkaisukanavassa. - + Current version Nykyinen versio - + Selected release channel Valitse julkaisukanava - - + + Update Available Päivitys Saatavilla - - + + A new version of Cockatrice is available! Uusi versio Cockatricesta on saatavilla! - - + + New version Uusi versio - - + + Released Julkaistu - - + + Changelog Muutosloki - + Do you want to update now? Haluatko päivittää nyt? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Päivitysvirhe - + An error occurred while checking for updates: Virhe päivityksiä tarkistettaessa: - + An error occurred while downloading an update: Virhe tiedostoa ladatessa: - + Installing... Asennetaan... - + Cockatrice is unable to open the installer. Cockatrice ei voi avata asennustiedostoa. - + Try to update manually by closing Cockatrice and running the installer. Yritä asentaa manuaalisesti sulkemalla Cockatrice ja käynnistämällä asennusohjelma. - + Download location Latausten sijainti @@ -3993,17 +5154,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing Tyhjennä loki sammutuksen yhteydessä - + Copy to clipboard - + Debug Log Debug loki @@ -4241,7 +5402,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4249,7 +5410,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Anna suodatin tähän @@ -4257,45 +5418,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4303,140 +5507,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Virhe - + Please join the appropriate room first. Liity ensin oikeaan huoneeseen. - + Wrong password. Väärä salasana. - + Spectators are not allowed in this game. Tähän peliin ei päästetä katsojia. - + The game is already full. Peli on jo täynnä. - + The game does not exist any more. Peliä ei enää ole. - + This game is only open to registered users. Peli on tarkoitettu vain rekisteröityneille käyttäjille. - + This game is only open to its creator's buddies. Peli on tarkoitettu vain sen tekijän kavereille. - + You are being ignored by the creator of this game. Pelin luoja ei huomioi sinua. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game Liity peliin - - Password: - Salasana: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Liity ensin asianmukaiseen huoneeseen. - + &Filter games &Suodata pelit - + C&lear filter &Poista suodattimet - + C&reate &Luo - + &Join &Liity - + Join as judge - + J&oin as spectator Liity &katsojana - + Join as judge spectator - + Games shown: %1 / %2 Games shown: %1 / %2 - + Games Pelit @@ -4477,105 +5697,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password salasana - + buddies only vain kaverit - + reg. users only vain rekisteröityneet käyttäjät - + open decklists - - + + can chat voi jutella - + see hands katso käsikortit - + can see hands voi nähdä käsikortit - + not allowed ei sallittu - + Room Huone - + Age Ikä - + Description Kuvaus - - Creator - Luoja + + Host + - + Type Muoto - + Restrictions Ehdot - + Players Pelaajat - + Spectators Katsojat @@ -4583,158 +5803,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + Suomi (Finnish) + + + + + + + + + Choose path Valitse polku - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Kieli: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Polut (editoiminen poistettu käytöstä kannettavassa versiossa) - + Paths Polut - + How to help with translations - + Decks directory: Pakkojen kansio: - + Filters directory: - + Replays directory: Replay kansio: - + Pictures directory: Kuvakansio: - + Card database: Korttitietokanta: - + Custom database directory: - + Token database: Tokenitietokanta: - + Update channel Päivitykset - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Ilmoita jos ohjelmastani puuttuu jokin serverin tarjoama ominaisuus - + Automatically run Oracle when running a new version of Cockatrice Suorita Oracle automaattisesti kun Cockatricen uusi versio on asennettu - + Show tips on startup Näytä vinkit käynnistyksen yhteydessä - + Last update check on %1 (%2 days ago) @@ -4742,47 +6079,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4876,59 +6213,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5146,273 +6777,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Pelaaja %1 - + Load replay Lataa replay - + About Cockatrice Tieto sovelluksesta - + Version Versio - + Cockatrice Webpage Cockatricen Webbsivut - + Project Manager: Projektipäällikkö: - + Past Project Managers: Edelliset projektipäälliköt: - + Developers: Kehittäjät: - + Our Developers Kehittäjämme - + Help Develop! Auta kehittämään! - + Translators: Kääntäjät: - + Our Translators Kääntäjät - + Help Translate! Auta Kääntämään! - + Support: Tuki: - + Report an Issue Ilmoita puute - + Troubleshooting Vianhaku - + F.A.Q. F.A.Q. - - + + Error Virhe - + &Connect... &Yhdistä... - + &Disconnect &Katkaise yhteys - + Start &local game... Aloita &paikallinen peli... - + &Watch replay... &Katso replay... - + &Full screen K&oko näyttö - + &Register to server... &Rekisteröidy serverille... - + &Restore password... - + &Settings... &Asetukset - + &Exit &Exit - + A&ctions &Toiminnot - + &Cockatrice &Cockatrice - + C&ard Database &Korttitietokanta - + &Manage sets... &Manage sets... - + Edit custom &tokens... Edit custom &tokens... - + Open custom image folder Avaa mukautettu kuvakansio - + Open custom sets folder Avaa mukautettu setit -kansio - + Add custom sets/cards Lisää mukautettuja settejä/kortteja - + Reload card database - + Tabs - + &Help &Apua - + &About Cockatrice &Tietoa Cockatricesta - + &Tip of the Day &Päivän vinkki - + Check for Client Updates Etsi päivityksiä - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version Uusi versio - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed Cockatrice asennettu - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Korttitietokanta - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5421,51 +7073,51 @@ Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes Kyllä - - + + No Ei - + Open settings Avaa asetukset - + New sets found Löytyi uusia settejä! - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets Näytä setit - + Welcome Tervetuloa - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5474,89 +7126,89 @@ All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information Tietoa - + A card database update is already running. Korttitietokannan päivitys on jo käynnissä - + Unable to run the card database updater: Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Lataa setit/kortit - + Selected file cannot be found. Selected file cannot be found. - + You can only import XML databases at this time. You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. Sets/cards failed to import. @@ -5761,615 +7413,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play pelistä - + from their graveyard Graveyardilta - + from exile exilestä - + from their hand kädestään - + the top card of %1's library %1n pakan päällimmäinen kortti - + the top card of their library pakan päällimmäisen kortin - + from the top of %1's library %1n pakan päältä - + from the top of their library Pakkansa päältä - + the bottom card of %1's library %1n pakan alimmainen kortti - + the bottom card of their library pakan alimmaisen kortin - + from the bottom of %1's library %1n pakan pohjalta - + from the bottom of their library pakan alta - + from %1's library %1n pakasta - + from their library pakasta - + from sideboard sideboardilta - + from the stack stäkistä - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 pelaa pakan päällimmäinen kortti %2 esillä. - + %1 is not revealing the top card %2 any longer. %1 ei pelaa enää pakan päällimmäinen kortti %2 esillä, - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 attaches %2 to %3's %4. - + %1 has conceded the game. %1 luovutti. - + %1 has unconceded the game. %1 has unconceded the game. - + %1 has restored connection to the game. %1 on jälleen yhteydessä peliin. - + %1 has lost connection to the game. %1 menetti yhteyden peliin. - + %1 points from their %2 to themselves. %1 osoittaa kortista %2 itseensä. - + %1 points from their %2 to %3. %1 osoittaa kortista %2 korttiin %3. - + %1 points from %2's %3 to themselves. %1 osoittaa %2n kortista %3 itseensä. - + %1 points from %2's %3 to %4. %1 osoittaa %2n kortista %3 korttiin %4 - + %1 points from their %2 to their %3. %1 osoittaa kortistaan %2 korttiin %3. - + %1 points from their %2 to %3's %4. %1 osoittaa kortistaan %2 pelaajan %3 korttiin %4. - + %1 points from %2's %3 to their own %4. %1 osoittaa pelaajan %2 kortista %3 korttiin %4. - + %1 points from %2's %3 to %4's %5. %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 tekee tokenin: %2%3. - + %1 has loaded a deck (%2). %1 on ladannut pakan (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 on ladannut pakan, jossa on %2 sideboard korttia (%3) - + %1 destroys %2. %1 tuhoaa kortin %2. - + a card kortti - + %1 gives %2 control over %3. %1 antaa pelaajalle %2 kontrollin korttiin %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 laittaa kortin %2 peliin%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 laittaa kortin %2%3 graveyardille - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 laittaa exileen kortin %2%3. - + %1 moves %2%3 to their hand. %1 laittaa kortin %2%3 käteen. - + %1 puts %2%3 into their library. %1 laittaa kortin %2%3 pakkaan. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1 laittaa kortin %2%3 pakan päälle. - + %1 puts %2%3 into their library %4 cards from the top. %1 laittaa kortin %2%3 pakkaansa %4 kortiksi päältä lukien. - + %1 moves %2%3 to sideboard. %1 siirtää kortin %2%3 sideboardille. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 pelaa kortin %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1 turns %2 face-down. - + %1 turns %2 face-up. %1 turns %2 face-up. - + The game has been closed. Peli on suljettu. - + The game has started. Peli alkaa. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 on liittynyt peliin. - + %1 is now watching the game. %1 seuraa peliä. - + You have been kicked out of the game. Sinut on kickattu pois pelistä. - + %1 has left the game (%2). %1 on lähtenyt pelistä. (%2). - + %1 is not watching the game any more (%2). %1 ei enää seuraa peliä (%2) - + %1 is not ready to start the game any more. %1 ei ole enää valmis aloittamaan peliä. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. %1 sekoittaa pakkansa ja nostaa uuden käden. - + You are watching a replay of game #%1. Katsot pelin #%1 replayta. - + %1 is ready to start the game. %1 on valmis aloittamaan pelin. - + cards an unknown amount of cards cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 reveals %2 to %3. - + %1 reveals %2. %1 reveals %2. - + %1 randomly reveals %2%3 to %4. %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. %1 reveals %2%3 to %4. - + %1 reveals %2%3. %1 reveals %2%3. - + %1 reversed turn order, now it's %2. %1 käänsi vuorojärjestyksen; nyt se on %2. - + reversed käänteinen - + normal normaali - + Heads Kruuna - + Tails Klaava - + %1 flipped a coin. It landed as %2. %1 heitti kolikkoa ja tulokseksi tuli %2. - + %1 rolls a %2 with a %3-sided die. %1 heitti tuloksen %2. %3 -sivuisella nopalla - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1n vuoro. - + %1 sets annotation of %2 to %3. %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 asettaa countterin %2 arvoon %3 (%4%5). - + %1 sets %2 to not untap normally. %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. %1 sets %2 to untap normally. - + %1 removes the PT of %2. %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 on lukinnut sideboardinsa. - + %1 has unlocked their sideboard. %1 on avannut sideboardinsa. - + %1 taps their permanents. %1 täppää kaikki permanentit. - + %1 untaps their permanents. %1 untäppää kaikki permanentit. - + %1 taps %2. %1 täppää kortin %2. - + %1 untaps %2. %1 untäppää kortin %2. - + %1 shuffles %2. %1 sekoittaa %2. - + %1 shuffles the bottom %3 cards of %2. %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 irrottaa kortin %2. - + %1 undoes their last draw. %1 peruuttaa viimeisimmän kortinnostonsa. - + %1 undoes their last draw (%2). %1 peruuttaa viimeisimmän kortinnostonsa. (%2). @@ -6377,115 +8029,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Lisää viesti - - + + Message: Viesti: - + Edit message Edit message - + Chat settings Keskusteluasetukset - + Custom alert words Custom alert words - + Enable chat mentions Salli keskustelumaininnat - + Enable mention completer Enable mention completer - + In-game message macros Pelinsisäiset viestimakrot - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users Ignore private messages sent by unregistered users - + Ignore private messages sent by non-buddy users - - + + Invert text color Käänteinen tekstin väri - + Enable desktop notifications for private messages Enable desktop notifications for private messages - + Enable desktop notification for mentions Enable desktop notification for mentions - + Enable room message history on join Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Väri on heksadesimaaliluku) - + Separate words with a space, alphanumeric characters only Separate words with a space, alphanumeric characters only @@ -6493,42 +8155,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6607,148 +8269,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6871,30 +8519,21 @@ Cockatrice will now reload the card database. End of turn step - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7013,16 +8652,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7043,12 +8938,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7056,25 +8951,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7105,28 +9040,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7142,95 +9077,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Services - + Hide %1 Hide %1 - + Hide Others Hide Others - + Show All Show All - + Preferences... Preferences... - + Quit %1 Quit %1 - + About %1 About %1 @@ -7238,17 +9181,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatrice card database (*.xml) - + All files (*.*) All files (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7268,17 +9211,22 @@ This setting means you'll only see the default printing for each card, instead o Tokens - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7289,7 +9237,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7300,100 +9248,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Save - + Save All Save All - + Open Avaa - + &Yes &Yes - + Yes to &All Yes to &All - + &No &Ei - + N&o to All N&o to All - + Abort Abort - + Retry Retry - + Ignore Sivuuta - + Close Close - + Cancel Cancel - + Discard Discard - + Help Help - + Apply Apply - + Reset Nollaa - + Restore Defaults Restore Defaults @@ -7401,121 +9359,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Nimi - + ID ID - + Upload time Upload time + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Nimi - + Players Players - + Keep Keep - + Time started Time started - + Duration (sec) Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7557,37 +9583,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Rooms - + Joi&n Joi&n - + Room Room - + Description Description - + Permissions Permissions - + Players Players - + Games Games @@ -7595,7 +9621,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7603,32 +9629,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Choose an action from the table - + Hit the key/combination of keys you want to set for this action Hit the key/combination of keys you want to set for this action - + Invalid key Invalid key - + Shortcut already in use by: - + Clear Clear - + Restore default Restore default @@ -7661,56 +9687,92 @@ Fine-tune individual colours in the grid afterwards. Release date + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Restore all default shortcuts - + Do you really want to restore all default shortcuts? Do you really want to restore all default shortcuts? - + Clear all default shortcuts Clear all default shortcuts - + Do you really want to clear all shortcuts? Do you really want to clear all shortcuts? - + Section: Section: - + Action: Action: - + Shortcut: Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7766,12 +9828,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7779,27 +9841,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Enable &sounds - + Current sounds theme: Current sounds theme: - + Test system sound engine Test system sound engine - + Sound settings Sound settings - + Master volume Master volume @@ -7807,48 +9869,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed Spoilers download failed - + No internet connection No internet connection - + Error Error - + Spoilers already up to date Spoilers already up to date - + No new spoilers added No new spoilers added - + Spoilers have been updated! Spoilers have been updated! - + Last change: Last change: @@ -7856,157 +9918,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. No reply received from the release update server. - + Invalid reply received from the release update server. Invalid reply received from the release update server. - + No reply received from the tag update server. No reply received from the tag update server. - + Invalid reply received from the tag update server. Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8014,17 +10089,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8145,118 +10236,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8269,42 +10360,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8317,52 +10408,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info Card Info - + Deck Deck - + Filters Filters - + &View &View - + Card Database - + Printing - + Visible Visible - + Floating Floating - + Reset layout Reset layout - + Deck: %1 Deck: %1 @@ -8370,49 +10461,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8420,22 +10511,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8443,139 +10534,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Local file system - + Server deck storage Server deck storage - - + + Open in deck editor Open in deck editor - + Rename deck or folder - + Upload deck Upload deck - + Download deck Download deck - - - - + + + + New folder New folder - - + + Delete Delete - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name Enter deck name - + This decklist does not have a name. Please enter a name: This decklist does not have a name. Please enter a name: - + Unnamed deck Unnamed deck - + Failed to upload deck to server - + Delete local file Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Name of new folder: - + Deck Storage @@ -8583,20 +10772,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8609,27 +11049,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8642,191 +11082,207 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Game - - + + Player List Player List - - + + + Invite + + + + + Card Info Card Info - - + + Messages Messages - - + + Replay Timeline Replay Timeline - + &Phases &Phases - + &Game &Game - + Next &phase Next &phase - + Next phase with &action Next phase with &action - + Next &turn Next &turn - + Reverse turn order Käännä vuorojärjestys - + &Remove all local arrows &Remove all local arrows - + Rotate View Cl&ockwise Rotate View Cl&ockwise - + Rotate View Co&unterclockwise Rotate View Co&unterclockwise - + Game &information Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede &Concede - + &Leave game &Leave game - + C&lose replay C&lose replay - + &Focus Chat &Focus Chat - + &Say: &Say: - + Selected cards - + &View &View - + Visible Visible - + Floating Floating - + Reset layout Reset layout - + Concede Concede - + Are you sure you want to concede this game? Are you sure you want to concede this game? - + Unconcede Unconcede - + You have already conceded. Do you want to return to this game? You have already conceded. Do you want to return to this game? - + Leave game Poistu pelistä - + Are you sure you want to leave this game? Haluatko varmasti poistua pelistä? - + A player has joined game #%1 Pelaaja on littynyt peliin #%1 - + %1 has joined the game - + You have been kicked out of the game. You have been kicked out of the game. @@ -8842,158 +11298,158 @@ Please enter a name: TabLog - + Logs Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs Room Logs - + Game Logs Game Logs - + Chat Logs Chat Logs - - + + Error Virhe - + You must select at least one filter. You must select at least one filter. - + You have to select a valid number of days to locate. You have to select a valid number of days to locate. - + Username: Käyttäjätunnus: - + IP Address: IP-osoite: - + Game Name: Pelin Nimi: - + GameID: Peli-ID: - + Message: Viesti: - + Main Room Main Room - + Game Room Game Room - + Private Chat Private Chat - + Past X Days: Past X Days: - + Today Tänään - + Last Hour Last Hour - + Maximum Results: Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs Get User Logs - + Clear Filters Clear Filters - + Filters Filters - + Log Locations Log Locations - + Date Range Date Range - + Maximum Results Maximum Results - - + + Message History Message History - + Failed to collect message history information. Failed to collect message history information. - + There are no messages for the selected filters. There are no messages for the selected filters. @@ -9001,218 +11457,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat Private &chat - + &Leave &Leave - + %1 - Private chat %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from Private message from - + %1 has left the server. %1 has left the server. - + %1 has joined the server. %1 has joined the server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Local file system - + Server replay storage Server replay storage - - + + Watch replay Watch replay - + Rename - - + + New folder - - + + Delete Delete - + Open replays folder - + Download replay Download replay - + Toggle expiration lock Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Delete remote replay @@ -9222,65 +12018,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Say: - + Chat Chat - + &Room &Room - + &Leave room &Leave room - + &Clear chat &Clear chat - + Chat Settings... Chat Settings... - + mentioned you. mentioned you. - + Click to view Click to view - + You are flooding the chat. Please wait a couple of seconds. You are flooding the chat. Please wait a couple of seconds. @@ -9288,35 +12497,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server Server - - - - + + + + Error Virhe - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9324,97 +12533,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Oletko varma? - + There are still open games. Are you sure you want to quit? There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Click to view - + Your buddy %1 has signed on! Your buddy %1 has signed on! - + Unknown Event Tuntematon Tapahtuma - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9425,39 +12710,39 @@ This message might mean there is a new version of Cockatrice available or this s To update your client, go to Help -> Check for Updates. - + Idle Timeout Idle Timeout - + You are about to be logged out due to inactivity. You are about to be logged out due to inactivity. - + Promotion Promotion - + You have been promoted. Please log out and back in for changes to take effect. You have been promoted. Please log out and back in for changes to take effect. - + Warned Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) You have received the following message from the server. @@ -9477,6 +12762,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9491,6 +12804,65 @@ Please refrain from engaging in this activity or further actions may be taken ag Unable to analyze the deck. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9562,57 +12934,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9620,216 +12987,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details User &details - + Private &chat Private &chat - + Show this user's &games Show this user's &games - + + View this user's &public decks + + + + Add to &buddy list Add to &buddy list - + Remove from &buddy list Remove from &buddy list - + Add to &ignore list Add to &ignore list - + Remove from &ignore list Remove from &ignore list - + Kick from &game Kick from &game - + + Report user + + + + Warn user Warn user - + View user's war&n history View user's war&n history - + Ban from &server Ban from &server - + View user's &ban history View user's &ban history - + &Promote user to moderator &Promote user to moderator - + Dem&ote user from moderator Dem&ote user from moderator - + Promote user to &judge - + Demote user from judge Demote user from judge - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games %1's games - - - + + + Ban History Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. User has never been banned. - + Failed to collect ban information. Failed to collect ban information. - - - + + + Warning History Warning History - + Warning Time;Moderator;User Name;Reason Warning Time;Moderator;User Name;Reason - + User has never been warned. User has never been warned. - + Failed to collect warning information. Failed to collect warning information. - + Failed to get admin notes. - - + + Success Success - + Successfully promoted user. Successfully promoted user. - + Successfully demoted user. Successfully demoted user. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Failed - + Failed to promote user. Failed to promote user. - + Failed to demote user. Failed to demote user. - + Copy hash to clipboard Copy hash to clipboard - + Remove this user's messages @@ -9867,61 +13284,61 @@ Please refrain from engaging in this activity or further actions may be taken ag Administrator - + Moderator Moderator - + Registered user Registered user - - + + Unregistered user Unregistered user - + Judge Judge - + Unknown Unknown - + The entered password does not match your account. - - - - + + + + Information Information - + User information updated. User information updated. - - - - - - + + + + + - - - + + + + Error Virhe @@ -9945,283 +13362,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. This server does not permit you to update your user informations. - + Password changed. Password changed. - + This server does not permit you to change your password. This server does not permit you to change your password. - + The new password is too short. The new password is too short. - + The old password is incorrect. The old password is incorrect. - + Avatar updated. Avatar updated. - + This server does not permit you to update your avatar. This server does not permit you to update your avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - This server does not permit you to update your user informations. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10229,223 +13655,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings General interface settings - + &Double-click cards to play them (instead of single-click) &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Annotate card text on tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Use tear-off menus, allowing right click menus to persist on screen - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Ilmoitusasetukset - + Enable notifications in taskbar Ota tehtäväpalkin ilmoitukset käyttöön - + Notify in the taskbar for game events while you are spectating Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect Notify in the taskbar when users in your buddy list connect - + Animation settings Animaatioasetukset - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &Tap/untap animation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10482,22 +14029,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10505,72 +14052,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10672,7 +14219,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10680,19 +14227,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10738,28 +14285,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10767,43 +14314,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10819,12 +14366,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10832,17 +14379,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10850,7 +14397,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10858,52 +14405,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10911,7 +14463,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10919,22 +14471,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10942,17 +14494,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10960,177 +14517,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancel - + Warn user for misconduct Warn user for misconduct - - + + Error Virhe - + User name to send a warning to can not be blank, please specify a user to warn. User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Move selected set to the top - + Move selected set up Move selected set up - + Move selected set down Move selected set down - + Move selected set to the bottom Move selected set to the bottom - - Search by set name, code, or type - Search by set name, code, or type - - - + Default order Default order - + Restore original art priority order Restore original art priority order - + Enable all sets Enable all sets - + Disable all sets Disable all sets - + Enable selected set(s) Enable selected set(s) - + Disable selected set(s) Disable selected set(s) - + Deck Editor Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Card Art - + + Search by set name, code, type, or release date + + + + How to use custom card art How to use custom card art - + Hints Hints - + Note Note - + Sorting by column allows you to find a set while not changing set priority. Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead Use the current sorting as the set priority instead - + Sorts the set priority using the same column Sorts the set priority using the same column - + Manage sets Manage sets @@ -11138,93 +14720,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing shuffle when closing - + pile view pile view - - i18n - - - English - Suomi (Finnish) - - main - + Connect on startup Connect on startup - + Debug to file Debug to file @@ -12008,6 +15582,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard Graveyard + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_fi.ts b/oracle/translations/oracle_fi.ts index e2a193ca4..95c223703 100644 --- a/oracle/translations/oracle_fi.ts +++ b/oracle/translations/oracle_fi.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Johdanto - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Tämä työkalu asentaa Cockatricessa käytettävät setit, kortit ja tokenit. - + Interface language: Käyttöliittymän kieli: - + Version: Versio: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Lähteen määritys - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Ole hyvä ja määritä lähde setti- ja korttilistalle. Voit määrittää ladattavan URL-osoitteen tai käyttää tietokoneellasi olevaa tiedostoa. - + Download URL: Ladattavan tiedoston URL-osoite: - + Local file: Paikallinen tiedosto: - + Restore default URL Palauta oletus-URL - + Choose file... Valitse tiedosto... - + Load sets file Ladatta ekspansiotiedosto - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Virhe - + The provided URL is not valid. Määritetty URL on virheellinen. - - Downloading (0MB) - Ladataan (0MB) - - - + Please choose a file. Valitse tiedosto. - + Cannot open file '%1'. Ei voida avata tiedostoa '%1'. - - Downloading (%1MB) - Ladataan (%1MB) - - - + Network error: %1. Yhteysvirhe: %1. - + Parsing file Jäsennellään tiedostoa - + Xz extraction failed. Xz purku epäonnistui. - + Sorry, this version of Oracle does not support xz compressed files. Pahoittelut. Tämä versio Oraclesta ei tue xz-pakattuja tiedostoja. - + Failed to open Zip archive: %1. Zip-tiedoston avaaminen epäonnistui: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Zip-tiedoston purkaminen epäonnistui: Zip-tiedosto ei sisällä tarkalleen yhtä tiedostoa. - + Zip extraction failed: %1. Zip-tiedoston purkaminen epäonnistuie: %1. - + Sorry, this version of Oracle does not support zipped files. Pahoittelut. Tämä versio Oraclesta ei tue Zip-pakattuja tiedostoja. - + Failed to interpret downloaded data. Ladattujen tietojen tulkitseminen epäonnistui. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Haluatko sensijaan ladata pakkaamattoman tiedoston? - + + The file was retrieved successfully, but it does not contain any sets data. Tiedosto palautettiin onnistuneesti, mutta se ei sisällä yhdenkään ekspansion dataa. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Tallenna spoileritietokanta - + XML; spoiler database (*.xml) XML; spoileritietokanta (*.xml) - + spoiler - + Spoilers import Spoilerien lataus - + Please specify a compatible source for spoiler data. Ole hyvä ja osoita yhteensopiva spoileridatan lähde. - + Download URL: Ladattavan tiedoston URL-osoite: - + Local file: - + Restore default URL Palauta oletus-URL - + Choose file... - + The spoiler database will be saved at the following location: Spoileritietokanta tallennetaan seuraavaan sijaintiin: - + Save to a custom path (not recommended) Tallenna mukautettuun sijaintiin (ei-suositeltu) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Tallenna tokenitietokanta - + XML; token database (*.xml) XML; tokenitietokanta (*.xml) - + tokens - + Tokens import Tokenien lataus - + Please specify a compatible source for token data. Ole hyvä ja osoita yhteensopiva tokenidatan lähde. - + Download URL: Ladattavan tiedoston URL-osoite: - + Local file: - + Restore default URL Palauta oletus-URL - + Choose file... - + The token database will be saved at the following location: Tokenitietokanta tallennetaan seuraavaan sijaintiin: - + Save to a custom path (not recommended) Tallenna mukautettuun sijaintiin (ei-suositeltu) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Tokeneja sisältävä mallisetti @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle-lataaja @@ -292,22 +306,22 @@ OutroPage - + Finished Valmis - + The wizard has finished. Asennusohjelma on valmis. - + You can now start using Cockatrice with the newly updated cards. Voit nyt käyttää Cockatricea juuri päivitetyillä korteilla. - + If the card databases don't reload automatically, restart the Cockatrice client. Jos korttitietokannat eivät päivity automaattisesti, käynnistä Cockatrice uudelleen. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Virhe - + + No set has been imported. Yhtään settiä ei ladattu. - + Sets imported Ladatut ekspansiot - - A cockatrice database file of %1 MB has been downloaded. - Cockatrice tietokantatiedosto kooltaan %1 MB on ladattu. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: Seuraavat setit löydettiin: - + Press "Save" to store the imported cards in the Cockatrice database. Paina "Tallenna" varastoidaksesi ladatut kortit Cockatricen tietokantaan. - + The card database will be saved at the following location: Korttitietokanta tallennetaan seuraavaan sijaintiin: - + Save to a custom path (not recommended) Tallenna mukautettuun sijaintiin (ei-suositeltu) - + &Save &Tallenna - + + Import finished: %1 cards. Lataaminen valmis: %1 kortit. - + + %1: %2 cards imported %1: %2 kortit ladattu - + Save card database Tallenna korttitietokanta - + XML; card database (*.xml) XML; korttitietokanta (*.xml) - + The file could not be saved to %1 Tiedostoa ei voitu tallentaa osoitteeseen %1 @@ -414,8 +431,9 @@ - Downloading (0MB) - Ladataan (0 MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,7 +605,7 @@ i18n - + English Suomi (Finnish) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Suorita vain spoileritilassa - + Run in no-confirm background mode From a19e0e459a667d8f2505385339aa7d9efb55f3d3 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:22:56 +0200 Subject: [PATCH 054/247] Updates for project Cockatrice and language ja (#7556) * Translate oracle_en@source.ts in ja [Manual Sync] 81% of minimum 5% translated source file: 'oracle_en@source.ts' on 'ja'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in ja [Manual Sync] 41% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'ja'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_ja.ts | 7448 ++++++++++++++++------ oracle/translations/oracle_ja.ts | 202 +- 2 files changed, 5626 insertions(+), 2024 deletions(-) diff --git a/cockatrice/translations/cockatrice_ja.ts b/cockatrice/translations/cockatrice_ja.ts index 322c70ac3..ddd1502ba 100644 --- a/cockatrice/translations/cockatrice_ja.ts +++ b/cockatrice/translations/cockatrice_ja.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... (&S)カウンターの数を決める... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter カウンターの設定 - + New value for counter '%1': '%1'カウンターの新しい値: @@ -36,65 +36,114 @@ AbstractTabDeckEditor - + Open in new tab 新しいタブで開く - + Are you sure? よろしいですか? - + The decklist has been modified. Do you want to save the changes? デッキリストが変更されています。 変更を保存しますか? - - - - - - + + + + + + Error エラー - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -106,12 +155,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes リリースノート - + Admin Notes for %1 @@ -137,218 +186,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error エラー - + Could not create themes directory at '%1'. '%1' にテーマディレクトリを作成できませんでした。 - + Theme settings テーマ設定 - + Current theme: 現在のテーマ - + Open themes folder テーマフォルダを開く - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings メニュー設定 - + Show keyboard shortcuts in right-click menus 右クリックメニューにキーボードショートカットを表示する - + Show game filter toolbar above list in room tab - + Card rendering カードの描画 - + Display card names on cards having a picture 画像のあるカードにカード名を表示 - + Auto-Rotate cards with sideways layout 横向きレイアウトのカードを自動回転 - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector デッキに含まれるカードを印刷セレクターの一番上に上げる - + Scale cards on mouse over マウスオーバーでカードを拡大 - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand スタックと手札垂直配置時の重なり率の最小値 - + Maximum initial height for card view window: カードビューウィンドウの初期高さの最大値 - - + + rows 行 - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 手札のレイアウト - + Display hand horizontally (wastes space) 手札を横に並べる - + Enable left justification 左揃えを有効 - + Table grid layout テーブルのレイアウト - + Invert vertical coordinate カード配置の垂直反転 - + Minimum player count for multi-column layout: プレイヤーを複数列レイアウトにする最少人数: - + Maximum font size for information displayed on cards: カードに表示する情報の最大のフォントサイズ: @@ -356,12 +491,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -387,112 +522,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name ユーザー名でBAN - + ban &IP address IPアドレスでBAN - + ban client I&D クライアントIDでBAN - + Ban type BANタイプ - + &permanent ban 永久BAN - + &temporary ban 一時BAN - + &Days: 日 - + &Hours: 時間 - + &Minutes: 分 - + Duration of the ban BAN期間 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. BANの理由を入力してください。 これはモデレーターによって保存されBANされた人には見えません。 - + Please enter the reason for the ban that will be visible to the banned person. BANの理由を入力してください。これはBANされる人に通知されます。 - + Redact all messages from this user in all rooms すべてのルームでこのユーザーからのすべてのメッセージを編集します - + &OK OK - + &Cancel キャンセル - + Ban user from server サーバーからBANされたユーザーです - - - - + + + + Error エラー - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. BANの理由を3 つの組み合わせを選択する必要があります:不適切な名前、不適切なIPアドレス、クライアントID - + You must have a value in the name ban when selecting the name ban checkbox. 名前BANチェックボックスを選択するときは、名前BANの値を持っている必要があります。 - + You must have a value in the ip ban when selecting the ip ban checkbox. IP BANチェックボックスを選択するときは、IP BANの値を持っている必要があります。 - + You must have a value in the clientid ban when selecting the clientid ban checkbox. クライアントID BANチェックボックスを選択するときは、クライアントID BANに値を持っている必要があります。 @@ -500,22 +635,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta Beta(開発中バージョン) - + No reply received from the release update server. リリース更新サーバーから応答がありませんでした。 - + Invalid reply received from the release update server. リリース更新サーバーから無効な応答が受信されました。 - + No reply received from the file update server. ファイル更新サーバーからの応答がありませんでした。 @@ -523,7 +658,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -531,22 +679,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -554,65 +702,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name カード名 - + Sets カードセット - + Mana cost マナ・コスト - + Card type カード・タイプ - + P/T P/T - + Color(s) 色 + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -722,22 +1014,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image カード画像 - + Description カード文章 - + Both 画像+文章 - + View transformation 別面を見る @@ -745,22 +1037,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards 関連カードを見る - + Add card to deck デッキにカードを追加 - + Mainboard メインボード - + Sideboard サイドボード @@ -768,27 +1060,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: カード名: - + Set: - + Collector Number: - + Related cards: 関連カード: - + Unknown card: 謎のカード: @@ -796,129 +1088,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -939,7 +1231,7 @@ This is only saved for moderators and cannot be seen by the banned person. CardSizeWidget - + Card Size カードの大きさ @@ -1079,6 +1371,24 @@ This is only saved for moderators and cannot be seen by the banned person. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1108,280 +1418,382 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1389,113 +1801,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1545,7 +1957,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1561,17 +1973,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1579,97 +1991,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1677,17 +2150,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1695,114 +2168,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1810,7 +2288,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1818,110 +2296,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers スポイラーを更新 - + Success 完了 - + Download URLs have been reset. ダウンロードURLをリセットしました。 - + Add URL URLを追加 - - + + URL: URL: - - + + Edit URL URLを編集 - + Add New URL 新しいURLを追加 - + Remove URL URLを削除 - + Updating... 更新中... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path パスを選ぶ - + URL Download Priority ダウンロードURL優先設定 - + Spoilers スポイラー - + Download Spoilers Automatically スポイラーを自動的にダウンロード - + Spoiler Location: スポイラーの保存場所: - + Last Change 最新に更新 - + Spoilers download automatically on launch スポイラーは起動時に自動的にダウンロードされます。 - + Press the button to manually update without relaunching ボタンを押すと再起動せずに手動で更新します。 - + Do not close settings until manual update is complete 手動更新が完了するまでこのウィンドウを閉じないでください。 - + Download card pictures on the fly カード画像を自動的にダウンロード(英語) - + How to add a custom URL URLの追加方法について(英語) - + Reset Download URLs ダウンロードURLをデフォルトに戻す @@ -1929,32 +2449,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1962,27 +2482,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count 枚数 - + Set セット - + Number 枚数 - + Provider ID プロバイダーID - + Card カード名 @@ -2003,17 +2523,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match 検索モード: 完全一致 - + Mode: Includes 検索モード: 一部に含む - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2021,7 +2546,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... タグを編集... @@ -2029,62 +2554,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager デッキタグマネージャー - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: デッキタグの管理ができます。新たなタグを追加することもできます: - + Add a new tag (e.g., Aggro️) 新しいタグを追加する(例: イニシアチブ) - + Add Tag タグを追加 - + Filter tags... タグをフィルタ... - + OK OK - + Edit default tags - + Cancel キャンセル - + Invalid Input 無効な入力 - + Tag name cannot be empty! タグ名を入力してください! - + Duplicate Tag タグの重複 - + This tag already exists. このタグはすでに存在します。 @@ -2092,94 +2617,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2187,57 +2727,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2259,87 +2844,115 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... デッキを開く... - + Load remote deck... サーバーのデッキを開く... - + Load from clipboard... - + Load from website... - + Unload deck デッキを撤去 - + Ready to start 準備完了!( ・`д・´) - + Force start 強制開始 - + Sideboard unlocked サイドボード使用可能 - + Sideboard locked サイドボードロック中 - - + + Error エラー - + The selected file could not be loaded. 選択したファイルが開けませんでした。 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion デッキフォーマット変換 - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? @@ -2348,7 +2961,7 @@ This will kick all non-ready players from the game. デッキをcod形式に変換しますか? - + Remember and automatically apply choice in the future 選択を記憶し、今後も自動的に適用する @@ -2361,143 +2974,143 @@ This will kick all non-ready players from the game. ダウンロード中... - + Known Hosts ホストを選択 - + Delete the currently selected saved server 選択中のサーバーを削除 - + Refresh the server list with known public servers デフォルトの公開サーバーリストに更新します - + New Host 新しいホスト - + Name: サーバー名: - + &Host: ホストアドレス: - + &Port: ポート: - + Player &name: プレイヤーネーム: - + P&assword: パスワード: - + &Save password パスワードを保存 - + A&uto connect 起動時に自動的に接続 - + Automatically connect to the most recent login when Cockatrice opens Cockatriceを開いた時に自動的に最後のログインに接続 - + If you have any trouble connecting or registering then contact the server staff for help! 接続や登録に問題がある場合は、サーバー管理者にお問い合わせください。 - - + + Webpage ウェブページ - + Reset Password パスワードをリセット - + Forgot password? パスワード忘れた - + &Connect 接続 - + Server サーバー - + Login ログイン - + Server Contact サーバーの連絡先 - + Connect to Server サーバーに接続 - + Server URL サーバーURL - + Communication Port 通信ポート - + Unique Server Name ユニークサーバー名 - + Connection Warning 接続の警告 - + You need to name your new connection profile. 新しい接続プロファイルに名前を付ける必要があります。 - + Connect Warning 接続の警告 - + The player name can't be empty. プレイヤー名を入力して下さい。 @@ -2505,122 +3118,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings 設定を保存する - + &Description: 説明: - + P&layers: プレイヤー人数: - + General 全般 - + Game type ゲームタイプ - + &Password: パスワード: - + Only &buddies can join フレンドのみ - + Only &registered users can join 登録プレイヤーのみ - + Joining restrictions 参加制限 - + &Spectators can watch 観戦を許可 - + Spectators &need a password to watch 観戦にパスワードが必要 - + Spectators can &chat 観戦者はチャットに参加できる - + Spectators can see &hands 観戦者に手札を見せる - + Create game as spectator 観戦者としてゲームを作成 - + Spectators 観戦者 - + Starting life total: 開始時のライフ: - + Open decklists in lobby - + Create game as judge - + Game setup options ゲーム準備オプション - + &Clear 設定をクリア - + Create game ゲームを作成 - + Game information ゲーム情報 - + Error エラー - + Server error. サーバーエラー。 @@ -2628,97 +3241,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: トークン名: - + Token トークン - + C&olor: 色: - + white 白 - + blue 青 - + black 黒 - + red 赤 - + green 緑 - + multicolor 多色 - + colorless 無色 - + &P/T: P/T: - + &Annotation: 注釈: - + &Destroy token when it leaves the table 戦場を離れる際に破棄 - + Create face-down (Only hides name) - + Token data トークン設定 - + Show &all tokens すべてのトークンを見る - + Show tokens from this &deck このデッキのトークンを見る - + Choose token from list 一覧からトークンを作成 - + Create token トークンを作成 @@ -2726,53 +3339,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2781,7 +3394,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. 画像がありません。 @@ -2793,27 +3406,32 @@ To remove your current avatar, confirm without choosing a new image. 現在のアバターを削除するには、新しい画像を選ばずにOKを押してください。 - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... 参照... - + Change avatar アバター変更 - + Open Image アバター画像を開く - + Image Files (*.png *.jpg *.bmp) 画像ファイル (*.png *.jpg *.bmp) - + Invalid image chosen. この画像は使用できません。 @@ -2839,38 +3457,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: 前のパスワード: - + New password: 新しいパスワード: - + Confirm new password: 新しいパスワード(確認): - + Change password パスワード変更 - - + + Error エラー - + Your password is too short. パスワードが短すぎます。 - + The new passwords don't match. 新しいパスワードが一致しませんでした。 @@ -2878,93 +3496,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: トークン名: - + C&olor: 色: - + white 白 - + blue 青 - + black 黒 - + red 赤 - + green 緑 - + multicolor 多色 - + colorless 無色 - + &P/T: P/T: - + &Annotation: 注釈: - + Token data トークンの設定 - - + + Add token トークンを追加 - + Remove token トークンを削除 - + Edit custom tokens カスタムトークンを編集 - + Please enter the name of the token: トークン名を入力: - + Error エラー - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 選択された名前は、既存のカードやトークンと競合しています。 @@ -3043,8 +3661,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started - 進行中のゲームを非表示 + Hide started games + Hide games that have started + @@ -3079,8 +3698,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - 作成者でフィルター: + &Host name: + &Creator name: + @@ -3146,52 +3766,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning パスワードリセットの警告 - + A problem has occurred. Please try to request a new password again. 問題が発生しました。新しいパスワードのリクエストを再試行してください。 - + Enter the information of the server and the account you'd like to request a new password for. 新しいパスワードをリクエストしたいサーバーの情報とアカウントを入力してください。 - + &Host: ホストアドレス: - + &Port: ポート: - + Player &name: プレイヤー名: - + Email: メールアドレス: - + Reset Password Challenge パスワードリセットチャレンジ - + Reset Password Challenge Error パスワードリセットエラー - + The email address can't be empty. メールアドレスを入力してください。 @@ -3199,37 +3819,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. 新しいパスワードをリクエストするサーバーの情報を入力してください。 - + &Host: ホストアドレス: - + &Port: ポート: - + Player &name: プレイヤー名: - + Reset Password Request パスワードリセット要求 - + Reset Password Error パスワードリセットエラー - + The player name can't be empty. プレイヤー名を入力して下さい。 @@ -3237,94 +3857,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning パスワードリセットの警告 - + A problem has occurred. Please try to request a new password again. 問題が発生しました。新しいパスワードのリクエストを再試行してください。 - + Enter the received token and the new password in order to set your new password. 新しいパスワードを設定するには、受け取ったトークンと新しいパスワードを入力します。 - + &Host: ホストアドレス: - + &Port: ポート: - + Player &name: プレイヤー名: - + Token: トークン: - - + + New Password: 新しいパスワード: - + Reset Password パスワードリセット - + The player name can't be empty. プレイヤー名を入力して下さい。 - - - - + + + + Reset Password Error パスワードリセットエラー - + The token can't be empty. トークンを入力してください。 - + The new password can't be empty. 新しいパスワードを入力してください。 - + Error エラー - + Your password is too short. パスワードが短すぎます。 - + The passwords do not match. パスワードが一致しませんでした。 + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck デッキを開く @@ -3408,166 +4061,567 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): カード名または検索条件: - + Number of hits: 枚数 - + Auto play hits 自動的にプレイ - + Put top cards on stack until... 見つかるまで上からスタックに置く... - + No cards matching the search expression exists in the card database. Proceed anyways? 条件に一致するカードはデータベースに存在しません。続行しますか? - + Cockatrice 悪魔からの助言 - + Invalid filter 不正な条件 + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. あなたの情報と登録したいサーバーの情報を入力してください。 あなたのメールアドレスはアカウントの確認に使用されます。 - + &Host: ホストアドレス: - + &Port: ポート: - + Player &name: プレイヤー名: - + P&assword: パスワード: - + Password (again): パスワード (確認) : - + Email: メールアドレス: - + Email (again): メールアドレス (確認) : - + Country: 国籍: - + Undefined 指定なし - + Real name: 本名: - + Register to server サーバーに登録 - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning 登録に関する警告 - + Your password is too short. パスワードが短すぎます。 - + Your passwords do not match, please try again. パスワードが一致しませんでした。もう一度入力して下さい。 - + Your email addresses do not match, please try again. メールアドレスが一致しませんでした。もう一度入力して下さい。 - + The player name can't be empty. プレイヤー名を入力して下さい。 + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3633,46 +4687,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database カードデータベース読み込みの不明なエラー。 - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - 無効なカードデータベースです。 - -Cockatriceは無効なデータベースでは正しく機能しない場合があります。 - -Oracle Importerでデータベースを更新する必要があります。 - -データベースの場所の設定を変更してみてください。 - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - 古いカードデータベースです。 - -カード情報や画像をロードする問題を引き起こす可能性があります。 - -Oracle Importerでデータベースを更新する必要があります。 - -データベースの場所の設定を変更してみてください。 - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3685,7 +4705,7 @@ Would you like to change your database location setting? データベースの場所の設定を変更しますか? - + File Error loading your card database. Would you like to change your database location setting? @@ -3694,7 +4714,7 @@ Would you like to change your database location setting? データベースの場所の設定を変更してみてください。 - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3703,7 +4723,7 @@ Would you like to change your database location setting? データベースの場所の設定を変更してみてください。 - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3716,77 +4736,208 @@ Would you like to change your database location setting? データベースの場所設定を変更しますか? - - - + + + Error エラー - + The path to your deck directory is invalid. Would you like to go back and set the correct path? あなたのデッキディレクトリへのパスは無効です。前に戻って正しいパスを設定してください。 - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? あなたのカード画像ディレクトリへのパスは無効です。前に戻って正しいパスを設定してください。 - + Settings 設定 - + + Search settings... + + + + + OK + + + + General 全般 - + Appearance 外観 - + User Interface UI - + Storage - + Card Sources カードダウンロード - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat チャット - + Sound サウンド - + Shortcuts ショートカット + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. @@ -3795,27 +4946,27 @@ You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3823,17 +4974,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next 次 - + Previous 前 - + Tip of the Day 今日のヒント @@ -3841,166 +4992,176 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel 現在のリリースチャンネル: - + Reinstall 再インストール - + Cancel Download ダウンロードをキャンセル - + Open Download Page ダウンロードページを開く - + Check for Client Updates クライアントの更新チェック - - - - + + + + Error エラー - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. CockatriceはSSLをサポートしていなかったため、更新を自動ダウンロード出来ません。 ダウンロードページから手動で更新してください。 - + Downloading update: %1 - + Checking for updates... 更新のチェック中... - + Finished checking for updates 更新のチェックが完了しました - + No Update Available 更新がありません - + Cockatrice is up to date! Cockatriceは最新版です。やったね! - + You are already running the latest version available in the chosen release channel. 選択したリリースチャンネルで最新のバージョンを使用しています。 - + Current version 現在のバージョン - + Selected release channel 選択したリリースチャンネル - - + + Update Available 更新できます!!! - - + + A new version of Cockatrice is available! 新しいバージョンのCockatriceが利用できます! - - + + New version 新しいバージョン - - + + Released 公開日時 - - + + Changelog 変更履歴 - + Do you want to update now? 今すぐ更新しますか? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. 残念ながら、自動アップデータは互換性のあるダウンロードを見つけることができませんでした。 You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error 更新エラー - + An error occurred while checking for updates: 更新のチェック中にエラーが発生しました: - + An error occurred while downloading an update: 更新のダウンロード中にエラーが発生しました: - + Installing... インストール中... - + Cockatrice is unable to open the installer. インストーラーが開けませんでした。 - + Try to update manually by closing Cockatrice and running the installer. Cockatriceを終了した後、インストーラーから手動で更新してください。 - + Download location ダウンロード場所 @@ -4008,17 +5169,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing 閉じるときにログをクリア - + Copy to clipboard - + Debug Log デバッグログ @@ -4256,7 +5417,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4264,7 +5425,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here ここにフィルタを入力 @@ -4272,45 +5433,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4318,140 +5522,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error エラー - + Please join the appropriate room first. 適切な部屋に参加してください。 - + Wrong password. パスワードが間違っています。 - + Spectators are not allowed in this game. このゲームは観戦出来ません。 - + The game is already full. このゲームはすでに満員です。 - + The game does not exist any more. このゲームはすでに存在しません。 - + This game is only open to registered users. このゲームは登録済みプレイヤーのみ参加できます。 - + This game is only open to its creator's buddies. このゲームは作成者のフレンドのみ参加できます。 - + You are being ignored by the creator of this game. あなたはゲームの作成者によって参加拒否されています。 - + Join Game 参加 - + Spectate Game 観戦 - + Game Information ゲーム情報 - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game 参加 - - Password: - パスワード: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. 最初にルームに参加してください。 - + &Filter games ゲームフィルタ - + C&lear filter フィルタ解除 - + C&reate ゲームを作成 - + &Join 参加 - + Join as judge - + J&oin as spectator 観戦 - + Join as judge spectator - + Games shown: %1 / %2 表示されているゲーム: %1 / %2 - + Games ゲーム @@ -4492,105 +5712,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 日 - + %1%2 hr short age in hours %1%2 時 - + new できたて - + %1%2 min short age in minutes %1%2 分 - + password パスワード - + buddies only フレンドのみ - + reg. users only 登録ユーザーのみ - + open decklists - - + + can chat チャット可 - + see hands 手札閲覧可 - + can see hands 手札閲覧可 - + not allowed 観戦不可 - + Room ルーム - + Age 作成時 - + Description 説明 - - Creator - 作成者 + + Host + - + Type タイプ - + Restrictions 制限 - + Players 人数 - + Spectators 観戦者 @@ -4598,158 +5818,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths すべてのパスをリセット - + All paths have been reset すべてのパスをリセットしました - - - - - - - + + + + English + 日本語 (Japanese) + + + + + + + + + Choose path パスを選択 - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: 言語: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) パス(ポータブルモードでの編集は無効) - + Paths パス - + How to help with translations - + Decks directory: デッキフォルダ: - + Filters directory: - + Replays directory: リプレイフォルダ: - + Pictures directory: カード画像フォルダ: - + Card database: カードデータベース: - + Custom database directory: カスタムデータベースフォルダ: - + Token database: トークンデータベース: - + Update channel 更新チャンネル - + Check for client updates on startup 起動時にクライアントの更新をチェック - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client サーバーでサポートされている機能がクライアントに存在しない場合に通知する - + Automatically run Oracle when running a new version of Cockatrice 新バージョンのCockatriceを起動したら自動的にOracleも起動する - + Show tips on startup 起動時にヒントを表示 - + Last update check on %1 (%2 days ago) @@ -4757,47 +6094,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4891,59 +6228,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5161,273 +6792,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 プレイヤー %1 - + Load replay リプレイを開く - + About Cockatrice Cockatriceについて - + Version バージョン - + Cockatrice Webpage Cockatriceウェブページ - + Project Manager: プロジェクトマネージャ: - + Past Project Managers: 元プロジェクトマネージャ: - + Developers: デベロッパ: - + Our Developers リンク先参照 - + Help Develop! 開発者募集中! - + Translators: 翻訳者: - + Our Translators 翻訳者一覧 - + Help Translate! 翻訳者募集中! - + Support: サポート: - + Report an Issue 問題を報告 - + Troubleshooting トラブルシューティング - + F.A.Q. F.A.Q. - - + + Error エラー - + &Connect... サーバーに接続... - + &Disconnect サーバーから切断 - + Start &local game... ローカルゲームを開始... - + &Watch replay... リプレイを見る... - + &Full screen フルスクリーン - + &Register to server... サーバーに登録する - + &Restore password... パスワードリセット... - + &Settings... 設定... - + &Exit 終了 - + A&ctions アクション - + &Cockatrice Cockatrice - + C&ard Database カードデータベース - + &Manage sets... セットを管理... - + Edit custom &tokens... カスタムトークンを編集... - + Open custom image folder カスタム画像フォルダを開く - + Open custom sets folder カスタムセットフォルダを開く - + Add custom sets/cards カスタムセット/カードを追加 - + Reload card database カードデータベースを再読み込み - + Tabs タブ - + &Help ヘルプ - + &About Cockatrice Cockatriceについて - + &Tip of the Day 今日のヒント - + Check for Client Updates クライアントの更新チェック - + Check for Card Updates... カードデータベース更新 - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log デバッグログを見る - + Open Settings Folder 設定フォルダを開く - + + Re-run Onboarding Wizard... + + + + Show/Hide 表示/非表示 - + + Sign-in cancelled + + + + New Version 新しいバージョン - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Cockatrice %1へのアップデートおめでとうございます! Oracleが起動し、カードデータベースが更新されます。 - + Cockatrice installed Cockatriceがインストールされました - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Cockatrice %1のインストールおめでとうございます! Oracleを起動して、初期カードデータベースをインストールします。 - + + Are you sure? + 確認 + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database カードデータベース - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5436,29 +7088,29 @@ If unsure or first time user, choose "Yes" よくわからない場合、初回起動時などは「はい」を押して下さい。 - - + + Yes はい - - + + No いいえ - + Open settings 設定を開く - + New sets found 新しいセットが見つかりました - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5467,22 +7119,22 @@ Do you want to enable it/them? 有効にしますか? - + Yes, always enable - + View sets セットを見る - + Welcome ようこそ - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5491,89 +7143,89 @@ Read more about changing the set order or disabling specific sets and consequent 「セットの設定」ウィンドウ内の特定のセットを無効にしたり、順序を変更をする方法の詳細をお読みください。 - + Information 情報 - + A card database update is already running. データベース更新は既に起動中です。 - + Unable to run the card database updater: データベース更新が実行できませんでした: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. プロセスへの書き込み中にエラーが発生しました。プロセスが実行されていないか、入力チャネルが閉じられている等の可能性があります。 - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards セット/カードを読み込む - + Selected file cannot be found. 選択したファイルが見つかりませんでした。 - + You can only import XML databases at this time. ここではXMLデータベースのみをインポートできます。 - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 新しいセット/カードが正常に追加されました。 データベースが再読込されます。 - + Sets/cards failed to import. セット/カードのインポートに失敗 @@ -5778,615 +7430,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play スタックから - + from their graveyard 墓地から - + from exile 追放領域から - + from their hand 手札から - + the top card of %1's library %1のライブラリーの一番上のカード - + the top card of their library ライブラリーの一番上のカード - + from the top of %1's library %1のライブラリーの一番上から - + from the top of their library ライブラリーの一番上から - + the bottom card of %1's library %1のライブラリーの一番下のカード - + the bottom card of their library ライブラリーの一番下のカード - + from the bottom of %1's library %1のライブラリーの一番下から - + from the bottom of their library ライブラリーの一番下のカード - + from %1's library %1のライブラリーから - + from their library ライブラリーから - + from sideboard サイドボードから - + from the stack スタックから - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 は%2一番上のカードを公開した状態でゲームをプレイしている。 - + %1 is not revealing the top card %2 any longer. %1 は%2一番上のカードの公開を終えた。 - + %1 can now look at top card %2 at any time. %1はいつでも%2一番上のカードを見ることができる。 - + %1 no longer can look at top card %2 at any time. %1はもう%2一番上のカードを見ることができない。 - + %1 attaches %2 to %3's %4. %1は%3の%4に%2をつけた。 - + %1 has conceded the game. %1 が投了した!!! - + %1 has unconceded the game. %1が投了を撤回した。 - + %1 has restored connection to the game. %1 がゲームに再接続した。 - + %1 has lost connection to the game. %1 はゲームから切断された。 - + %1 points from their %2 to themselves. %1 は %2 から自分自身へ対象を指定した。 - + %1 points from their %2 to %3. %1 は %2 から %3 へ対象を指定した。 - + %1 points from %2's %3 to themselves. %1 は %2 の %3 から自分自身へ対象を指定した。 - + %1 points from %2's %3 to %4. %1 は %2 の %3 から %4 へ対象を指定した。 - + %1 points from their %2 to their %3. %1 は %2 から %3 へ対象を指定した。 - + %1 points from their %2 to %3's %4. %1 は %2 から %3 の %4 へ対象を指定した。 - + %1 points from %2's %3 to their own %4. %1 は %2 の %3 から %4 へ対象を指定した。 - + %1 points from %2's %3 to %4's %5. %1 は %2 の %3 から %4 の %5 へ対象を指定した。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1 はトークン: %2 を%3作成した。 - + %1 has loaded a deck (%2). %1 はデッキをロードした。ハッシュ:%2 - + %1 has loaded a deck with %2 sideboard cards (%3). %1 はデッキをロードした。サイドボード数:%2, ハッシュ:%3 - + %1 destroys %2. %1 は %2 を取り除いた。 - + a card カード - + %1 gives %2 control over %3. %1 は %2 に %3 のコントロールを渡した。 - + %1 puts %2 into play%3 face down. %1 は %2 を%3裏向きでプレイした。 - + %1 puts %2 into play%3. %1 は %2 を%3プレイした。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 は %2 を%3墓地に置いた。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 は %2 を%3追放した。 - + %1 moves %2%3 to their hand. %1は %2 を%3手札に加えた。 - + %1 puts %2%3 into their library. %1 は %2 を%3ライブラリーに加えた。 - + %1 puts %2%3 onto the bottom of their library. %1 は %2 を%3ライブラリーの一番下に置いた。 - + %1 puts %2%3 on top of their library. %1 は %2 を%3ライブラリーの一番上に置いた。 - + %1 puts %2%3 into their library %4 cards from the top. %1 は %2 を%3ライブラリーの一番上から%4枚目に置いた。 - + %1 moves %2%3 to sideboard. %1 は %2 を%3サイドボードに置いた。 - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 は %2 を%3プレイした。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 は……カードがないライブラリーからカードを引こうとした! - + %1 draws %2 card(s). %1は%2枚カードを引いた。 - + %1 is looking at %2. %1 は%2を見ている。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 は%2%4%3枚のカードを見ている。 - + bottom 下から - + top 上から - + %1 turns %2 face-down. %1 は %2 を裏向きにした。 - + %1 turns %2 face-up. %1 は %2 を表向きにした。 - + The game has been closed. ゲームがクローズされました。 - + The game has started. ◇◇ゲーム開始!◇◇ - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 がゲームに参加した。 - + %1 is now watching the game. %1 が観戦に参加した。 - + You have been kicked out of the game. ゲームからキックされました。 - + %1 has left the game (%2). %1 はゲームから離脱した(%2)。 - + %1 is not watching the game any more (%2). %1 が観戦から離脱した(%2)。 - + %1 is not ready to start the game any more. %1 は準備完了を解除した。 - + %1 shuffles their deck and draws a new hand of %2 card(s). %1はライブラリーを切り直し、新たにカードを%2枚引いた。 - + %1 shuffles their deck and draws a new hand. %1はライブラリーを切り直し、新たに手札を引いた。 - + You are watching a replay of game #%1. ゲーム#%1のリプレイを再生しています。 - + %1 is ready to start the game. %1 はゲーム開始の準備が完了した!! - + cards an unknown amount of cards カード - + %1 card(s) a card for singular, %1 cards for plural %1枚のカード - + %1 lends %2 to %3. %1 は %3 に%2を貸した。 - + %1 reveals %2 to %3. %1 は %3 に%2を見せた。 - + %1 reveals %2. %1 は%2を公開した。 - + %1 randomly reveals %2%3 to %4. %1 は%3無作為に %2 を選び、%4に見せた。 - + %1 randomly reveals %2%3. %1 は%3無作為に %2 を選び、公開した。 - + %1 peeks at face down card #%2. %1 は裏向きのカード#%2の表面を確認した。 - + %1 peeks at face down card #%2: %3. %1 は裏向きのカード#%2の表面を確認した ( %3 ) 。 - + %1 reveals %2%3 to %4. %1 は%3 %2 を %4 に見せた。 - + %1 reveals %2%3. %1 は%3 %2 を公開した。 - + %1 reversed turn order, now it's %2. %1はターン順を逆にした(%2)。 - + reversed 逆転 - + normal 順転 - + Heads 表 - + Tails 裏 - + %1 flipped a coin. It landed as %2. %1 はコインを投げた。結果は……【%2】だ! - + %1 rolls a %2 with a %3-sided die. %1 は%3面ダイスをふり、【%2】を出した。 - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 は%2枚のコインを投げた。表が【%3】枚、裏が【%4】枚出た。 - + %1 rolls a %2-sided dice %3 times: %4. %1 は%2面体サイコロを%3個振り、それぞれ【%4】を出した。 - + %1's turn. ■ %1 のターン■ - + %1 sets annotation of %2 to %3. %1 は %2 に注釈をつけた (%3)。 - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 は%2カウンターを%3に設定した (%4%5)。 - + %1 sets %2 to not untap normally. %1 は %2 をアンタップ・ステップの間にアンタップしないようにした。 - + %1 sets %2 to untap normally. %1 は %2 を通常どおりアンタップするように設定した。 - + %1 removes the PT of %2. %1 は %2 のP/Tを取り除いた。 - + %1 changes the PT of %2 from nothing to %4. %1 は %2 のP/Tを%4にした。 - + %1 changes the PT of %2 from %3 to %4. %1 は %2 のP/Tを%3から%4にした。 - + %1 has locked their sideboard. %1 はサイドボードをロックした。 - + %1 has unlocked their sideboard. %1 はサイドボードを解禁した。 - + %1 taps their permanents. %1 は自分のコントロールするパーマネントをタップした。 - + %1 untaps their permanents. %1 は自分のコントロールするパーマネントをアンタップした。 - + %1 taps %2. %1 は %2 をタップした。 - + %1 untaps %2. %1 は %2 をアンタップした。 - + %1 shuffles %2. %1 は%2を切り直した。 - + %1 shuffles the bottom %3 cards of %2. %1 は%2の一番下の%3枚のカードをに無作為の順番で置いた。 - + %1 shuffles the top %3 cards of %2. %1 は%2の一番上の%3枚のカードをに無作為の順番で置いた。 - + %1 shuffles cards %3 - %4 of %2. %1 は%2の %3 - %4 枚を無作為の順番で置いた。 - + %1 unattaches %2. %1 は %2 をはずした。 - + %1 undoes their last draw. %1 は最後に引いたカードを戻した。 - + %1 undoes their last draw (%2). %1 は最後に引いたカード ( %2 ) を戻した 。 @@ -6394,115 +8046,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 ここに入力してください - + Add New Message 定型文を追加 - + Edit Message 定型文を編集 - + Remove Message 定型文を削除 - + Add message 定型文を追加 - - + + Message: 定型文: - + Edit message 定型文を編集 - + Chat settings チャット設定 - + Custom alert words 反応するキーワード - + Enable chat mentions メンション機能を有効 - + Enable mention completer メンション補完を有効 - + In-game message macros 定型文 - + How to use in-game message macros ゲーム内メッセージマクロの使い方 - + Ignore chat room messages sent by unregistered users 未登録ユーザーのルームメッセージを無視 - + Ignore private messages sent by unregistered users 未登録ユーザーの個人チャットを無視 - + Ignore private messages sent by non-buddy users - - + + Invert text color 反転テキストの色 - + Enable desktop notifications for private messages 個人チャットのデスクトップ通知を有効 - + Enable desktop notification for mentions メンションのデスクトップ通知を有効 - + Enable room message history on join 参加時にルームのメッセージ履歴を表示 - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (色は16進数) - + Separate words with a space, alphanumeric characters only 英数字のみ使用可能。単語を半角スペースで区切って下さい。 @@ -6510,42 +8172,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6624,148 +8286,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6888,30 +8536,21 @@ Cockatrice will now reload the card database. 最終フェイズ - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - ja - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7030,16 +8669,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7060,12 +8955,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7073,25 +8968,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference 優先設定 - + Pin Printing ピン留めする - + Unpin Printing ピン留めを外す - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards 関連カードを見る + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7122,28 +9057,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical 名前順 - + Preference - + Release Date 提供日時順 - - + + Descending 降順 - + Ascending 昇順 @@ -7159,95 +9094,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services サービス - + Hide %1 %1を隠す - + Hide Others 他を隠す - + Show All すべて表示 - + Preferences... 設定... - + Quit %1 %1から出る - + About %1 %1について @@ -7255,17 +9198,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatriceカードデータベース(*.xml) - + All files (*.*) 全てのファイル (*.*) - + Cockatrice replays (*.cor) Cockatriceリプレイファイル (*.cor) @@ -7285,17 +9228,22 @@ This setting means you'll only see the default printing for each card, instead o トークン - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7306,7 +9254,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7317,100 +9265,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save 保存 - + Save All 全て保存 - + Open 開く - + &Yes はい - + Yes to &All すべてはい - + &No いいえ - + N&o to All すべていいえ - + Abort 中止 - + Retry 再試行 - + Ignore 無視 - + Close 閉じる - + Cancel キャンセル - + Discard カードを捨てる - + Help ヘルプ - + Apply 適用 - + Reset リセット - + Restore Defaults デフォルトを復元 @@ -7418,121 +9376,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name デッキ名 - + ID ID - + Upload time アップロード日 + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name 説明 - + Players 参加プレイヤー - + Keep ロック - + Time started 開始日時 - + Duration (sec) 期間 (秒) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7574,37 +9600,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms ルーム - + Joi&n 参加する - + Room ルーム名 - + Description 説明 - + Permissions アクセス権限 - + Players 人数 - + Games ゲーム数 @@ -7612,7 +9638,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7620,32 +9646,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table 表の中からアクションを選択 - + Hit the key/combination of keys you want to set for this action このアクションに設定するキー(の組み合わせ)を押してください - + Invalid key 無効なキー - + Shortcut already in use by: ショートカットは既に使われています: - + Clear クリア - + Restore default デフォルトを復元 @@ -7678,56 +9704,92 @@ Fine-tune individual colours in the grid afterwards. 公開日時 + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts すべてのショートカットを元に戻す - + Do you really want to restore all default shortcuts? 本当にすべてのショートカットキーを元に戻しますか? - + Clear all default shortcuts すべてのショートカットをクリア - + Do you really want to clear all shortcuts? 本当にすべてのショートカットキーをクリアしますか? - + Section: セクション: - + Action: アクション: - + Shortcut: ショートカット: - + + Shortcut editor + + + + How to set custom shortcuts ショートカットキーの設定方法について(英語) - + Clear all shortcuts すべてのショートカットをクリア - + Search by shortcut name ショートカット名で検索 @@ -7783,12 +9845,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7796,27 +9858,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds サウンドを有効 - + Current sounds theme: 現在のサウンドテーマ: - + Test system sound engine サウンドテスト - + Sound settings サウンド設定 - + Master volume 音量 @@ -7824,48 +9886,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended スポイラーシーズンが終了しました - + Deleting spoiler.xml. Please run Oracle spoiler.xmlを削除します。Oracleを起動してください。 - - + + Spoilers download failed スポイラーのダウンロードに失敗しました。 - + No internet connection インターネット接続がありません(´・ω・`) - + Error エラー - + Spoilers already up to date スポイラーリストは最新です。 - + No new spoilers added 新たなスポイラーはありません。 - + Spoilers have been updated! スポイラーが更新されました!! - + Last change: 最後の変更: @@ -7873,157 +9935,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default デフォルト - + No reply received from the release update server. リリース更新サーバーから応答がありませんでした。 - + Invalid reply received from the release update server. リリース更新サーバーから無効な応答が受信されました。 - + No reply received from the tag update server. タグ更新サーバーからの応答がありませんでした。 - + Invalid reply received from the tag update server. タグ更新サーバーから無効な応答が受信されました。 + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8031,17 +10106,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List フレンドリストに追加する名前を入力 - + Add to Ignore List 無視リストに追加する名前を入力 - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account アカウント @@ -8162,118 +10253,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8286,42 +10377,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8334,52 +10425,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info カード情報 - + Deck デッキ - + Filters フィルタ - + &View レイアウト - + Card Database - + Printing - + Visible 表示する - + Floating 別ウィンドウにする - + Reset layout レイアウトをリセット - + Deck: %1 デッキ: %1 @@ -8387,49 +10478,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8437,22 +10528,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8460,139 +10551,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system ローカルのデッキ - + Server deck storage サーバーに保存されているデッキ - - + + Open in deck editor デッキエディターで開く - + Rename deck or folder - + Upload deck デッキをアップロード - + Download deck デッキをダウンロード - - - - + + + + New folder 新しいフォルダーを作成する - - + + Delete 削除 - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error エラー - + Rename failed - - + + + Invalid deck file 無効なデッキファイル - + Enter deck name デッキの名前を入力 - + This decklist does not have a name. Please enter a name: このデッキリストには名前がありません。 名前を入力してください: - + Unnamed deck 無名のデッキ - + Failed to upload deck to server - + Delete local file ローカルファイルの削除 - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? 選択したデッキを削除してもよろしいですか? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: 新しいフォルダの名前: - + Deck Storage デッキストレージ @@ -8600,20 +10789,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage ビジュアルデッキストレージ - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8626,27 +11066,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8659,191 +11099,207 @@ Please enter a name: TabGame - - - + + + Replay リプレイ - - + + Game ゲーム - - + + Player List プレイヤーリスト - - + + + Invite + + + + + Card Info カード情報 - - + + Messages メッセージ - - + + Replay Timeline リプレイタイムライン - + &Phases フェイズ - + &Game ゲーム - + Next &phase 次のフェイズ - + Next phase with &action ターン起因処理をしながら次のフェイズ - + Next &turn 次のターン - + Reverse turn order ゲームのターン順を逆転する - + &Remove all local arrows 全ての対象指定を消す - + Rotate View Cl&ockwise 席を時計回りに回転 - + Rotate View Co&unterclockwise 席を反時計回りに回転 - + Game &information ゲーム情報 - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede 投了を撤回 - - - + + + &Concede 投了する - + &Leave game ゲームから離脱する - + C&lose replay リプレイを閉じる - + &Focus Chat - + &Say: 発言欄: - + Selected cards - + &View レイアウト - + Visible 表示する - + Floating 別ウィンドウにする - + Reset layout レイアウトをリセット - + Concede 投了する - + Are you sure you want to concede this game? 本当にこのゲームを投了しますか? - + Unconcede 投了を取り消す - + You have already conceded. Do you want to return to this game? あなたはすでに投了しています! 本当にゲームに復帰しますか? - + Leave game ゲームから離脱する - + Are you sure you want to leave this game? 本当にこのゲームから離脱しますか? - + A player has joined game #%1 プレイヤーがゲーム#%1に参加した。 - + %1 has joined the game %1 がゲームに参加した - + You have been kicked out of the game. ゲームからキックされました。 @@ -8859,158 +11315,158 @@ Please enter a name: TabLog - + Logs ログ - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName 時間;送信者名;送信者IP;メッセージ;対象ID;対象名 - + Room Logs ルームログ - + Game Logs ゲームログ - + Chat Logs チャットログ - - + + Error エラー - + You must select at least one filter. フィルターを選んで下さい。 - + You have to select a valid number of days to locate. 検索するには、有効な日数を選択する必要があります。 - + Username: ユーザー名: - + IP Address: IPアドレス: - + Game Name: ゲーム名: - + GameID: ゲームID: - + Message: メッセージ: - + Main Room メインルーム - + Game Room ゲームルーム - + Private Chat プライベートチャット - + Past X Days: X日経過: - + Today 今日 - + Last Hour 今 - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. 少なくとも一つのフィルタが必要です。 情報を入力すればするほど詳細な検索結果になります。 - + Get User Logs ユーザーログを取得 - + Clear Filters フィルタ解除 - + Filters フィルタ - + Log Locations ログの場所 - + Date Range 日付指定 - + Maximum Results - - + + Message History メッセージ履歴 - + Failed to collect message history information. メッセージ履歴情報の収集に失敗しました。 - + There are no messages for the selected filters. 選択したフィルタのためのメッセージはありません。 @@ -9018,218 +11474,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat 個人チャット - + &Leave 退出する - + %1 - Private chat %1 - 個人チャット - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. このユーザーはあなたを無視しています。メインチャットのメッセージは表示されず、ゲームに参加することもできません。 - + Private message from 個人チャット: - + %1 has left the server. %1はサーバーから退出しました。 - + %1 has joined the server. %1がサーバーに参加しました。 + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system ローカルのリプレイ - + Server replay storage サーバーに保存されたリプレイ - - + + Watch replay リプレイを再生 - + Rename 名前変更 - - + + New folder 新しいフォルダ - - + + Delete 削除 - + Open replays folder リプレイフォルダを開く - + Download replay リプレイをダウンロード - + Toggle expiration lock 有効期限ロックの切り替え - + Get replay share code - - + + Look up replay by share code - + Rename local folder ローカルフォルダの名前を変更 - + Rename local file ローカルファイルの名前を変更 - + New name: 新しい名前: - + Error エラー - + Rename failed 変更失敗 - + Name of new folder: 新しいフォルダの名前: - + Delete local file ローカルファイルの削除 - + Are you sure you want to delete the selected files? 選択したファイルを削除してもよろしいですか? - + Are you sure you want to delete the selected replays? 選択したリプレイを削除してもよろしいですか? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay サーバーファイルの削除 @@ -9239,65 +12035,478 @@ The more information you put in, the more specific your results will be.リプレイ + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: 発言欄: - + Chat チャット - + &Room ルーム - + &Leave room ルームから出る - + &Clear chat チャットをクリア - + Chat Settings... チャット設定... - + mentioned you. あなたにメンションした。 - + Click to view クリックで見る - + You are flooding the chat. Please wait a couple of seconds. あなたはチャットルームから弾かれました。少々お待ちください。 @@ -9305,35 +12514,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server サーバー - - - - + + + + Error エラー - + Failed to join the server room: it doesn't exist on the server. サーバールームに参加できませんでした: サーバーに存在しません。 - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. サーバーからサーバールームに入室中の応答がありましたが、クライアントが表示できませんでした。クライアントを再起動してみてください。 - + You do not have the required permission to join this server room. あなたはこのサーバールームに参加するための権限がありません。 - + Failed to join the server room due to an unknown error: %1. 原因不明のエラーによってサーバールームに参加できませんでした: %1。 @@ -9341,97 +12550,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor デッキエディター - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage ビジュアルデッキストレージ - + Visual Database Display - + Server サーバー - + Account アカウント - + Deck Storage デッキストレージ - + Game Replays リプレイ - + Administration 管理 - + Logs ログ - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? よろしいですか? - + There are still open games. Are you sure you want to quit? ゲームがまだ開いています。本当に退出しますか? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view クリックで見る - + Your buddy %1 has signed on! %1がオンライン! - + Unknown Event 不明なイベント - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9442,39 +12727,39 @@ To update your client, go to Help -> Check for Updates. クライアントを更新するには、ヘルプ→更新のチェックをクリックしてください。 - + Idle Timeout 放置タイムアウト - + You are about to be logged out due to inactivity. 非アクティブのためログアウトしています。 - + Promotion 昇格 - + You have been promoted. Please log out and back in for changes to take effect. あなたは昇格されました。変更を有効にするためにログインしなおして下さい。 - + Warned 警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 以下の理由によって警告を受けました: %1。 これらのような行動はご遠慮頂ますようお願い致します。質問がある場合は、モデレーターにプライベートメッセージを送信してください。 - + You have received the following message from the server. (custom messages like these could be untranslated) サーバーから次のメッセージを受け取りました。 @@ -9494,6 +12779,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9508,6 +12821,65 @@ Please refrain from engaging in this activity or further actions may be taken ag デッキを分析できませんでしたぁ…… + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9579,57 +12951,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9637,216 +13004,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details ユーザー詳細 - + Private &chat プライベートチャット - + Show this user's &games このユーザーのゲームを表示 - + + View this user's &public decks + + + + Add to &buddy list フレンドリストに追加 - + Remove from &buddy list フレンドリストから削除 - + Add to &ignore list 無視リストに追加 - + Remove from &ignore list 無視リストから削除 - + Kick from &game ゲームからキックする - + + Report user + + + + Warn user ユーザーに警告する - + View user's war&n history ユーザーの警告履歴を見る - + Ban from &server サーバーからBANする - + View user's &ban history ユーザーのBAN履歴を見る - + &Promote user to moderator ユーザーを昇格する - + Dem&ote user from moderator ユーザーを降格する - + Promote user to &judge ユーザーをジャッジに昇格する - + Demote user from judge ユーザーをジャッジから降格する - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes 管理者ノートを見る - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games %1のゲーム - - - + + + Ban History BAN履歴 - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason BAN回数;モデレーター;BAN期間;BAN理由;可視理由 - + User has never been banned. ユーザーはBANされていません。 - + Failed to collect ban information. BAN情報の収集に失敗しました。 - - - + + + Warning History 警告履歴 - + Warning Time;Moderator;User Name;Reason 警告回数;モデレーター;ユーザー名;理由 - + User has never been warned. ユーザーは警告されていません。 - + Failed to collect warning information. 警告情報の収集に失敗しました。 - + Failed to get admin notes. 管理者ノートの取得に失敗しました。 - - + + Success 成功 - + Successfully promoted user. ユーザーを昇格しました。 - + Successfully demoted user. ユーザーを降格しました。 - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed 失敗 - + Failed to promote user. 昇格に失敗しました。 - + Failed to demote user. 降格に失敗しました。 - + Copy hash to clipboard クリップボードにハッシュをコピー - + Remove this user's messages このユーザーのメッセージを削除 @@ -9884,61 +13301,61 @@ Please refrain from engaging in this activity or further actions may be taken ag 管理者 - + Moderator モデレーター - + Registered user 登録ユーザー - - + + Unregistered user 未登録ユーザー - + Judge ジャッジ - + Unknown 不明 - + The entered password does not match your account. 入力されたパスワードはアカウントと一致しません。 - - - - + + + + Information 情報 - + User information updated. ユーザー情報が更新されました。 - - - - - - + + + + + - - - + + + + Error エラー @@ -9962,283 +13379,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n年 - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n日%20 - + Enter Password パスワードを入力 - + Password verification is required in order to change your email address メールアドレスを変更するには、パスワード認証が必要です - - - + + + An error occurred while trying to update your user information. ユーザー情報の更新中にエラーが発生しました。 - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. このサーバーはユーザーの情報を更新することができません。 - + Password changed. パスワードが変更されました。 - + This server does not permit you to change your password. このサーバーはパスワードを変更することができません。 - + The new password is too short. 新しいパスワードが短すぎます。 - + The old password is incorrect. 前のパスワードが一致しませんでした。 - + Avatar updated. アバターを更新しました。 - + This server does not permit you to update your avatar. このサーバーはアバターを更新することができません。 - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - このサーバーはユーザーの情報を更新することができません。 - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10246,223 +13672,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings インターフェース全般設定 - + &Double-click cards to play them (instead of single-click) ダブルクリックでカードをプレイする (シングルクリックの代わり) - + &Clicking plays all selected cards (instead of just the clicked card) クリックしたカードだけでなく、選択したすべてのカードをプレイする。 - + &Play all nonlands onto the stack (not the battlefield) by default 土地でないカードをプレイする時、すぐに戦場に出さずにスタックに置く - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed 最後のカードが削除されたときにカード表示ウィンドウを閉じる - + Auto focus search bar when card view window is opened - + Annotate card text on tokens トークンのカードテキストを注釈にもつける - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen ティアオフメニューを使用して、右クリックメニューを画面に表示したままにする - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings 通知設定 - + Enable notifications in taskbar タスクバーの通知を有効 - + Notify in the taskbar for game events while you are spectating タスクバーに観戦中のゲームイベントを通知する - + Notify in the taskbar when users in your buddy list connect フレンドリストのユーザーが接続したときにタスクバーで通知する - + Animation settings アニメーション設定 - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation タップ/アンタップアニメーション - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings デッキエディタ/ストレージ設定 - + Open deck in new tab by default デフォルトでデッキを新しいタブで開く - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby ゲームロビーでビジュアルデッキストレージを使用 - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings リプレイ設定 - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 ユーザーがサーバーに接続: %1 - + Users in this room: %1 ルームのユーザー数: %1 - + Buddies online: %1 / %2 フレンドオンライン: %1 / %2 - + Ignored users online: %1 / %2 無視ユーザーオンライン: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10499,22 +14046,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10522,72 +14069,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10689,7 +14236,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10697,19 +14244,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10755,28 +14302,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10784,43 +14331,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10836,12 +14383,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10849,17 +14396,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter データベース内で最も近いものを検索し(自動提案付き)、Enter キーを押すと、優先印刷がデッキに追加されます。 @@ -10867,7 +14414,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage デッキストレージ @@ -10875,52 +14422,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10928,7 +14480,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10936,22 +14488,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) デッキ名順 - + Sort Alphabetically (Filename) ファイル名順 - + Sort by Last Modified 更新日順 - + Sort by Last Loaded 最近使った順 @@ -10959,17 +14511,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... データベースをロード中... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10977,177 +14534,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? どの警告を送信しますか? - + Redact all messages from this user in all rooms すべてのルームでこのユーザーからのすべてのメッセージを編集します - + &OK OK - + &Cancel キャンセル - + Warn user for misconduct 不正行為をユーザーに警告 - - + + Error エラー - + User name to send a warning to can not be blank, please specify a user to warn. ユーザー名は空欄に出来ません。有効なユーザー名を選択して下さい。 - + Warning to use can not be blank, please select a valid warning to send. 警告は空欄に出来ません。有効な警告を選択して下さい。 + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top 選択したセットを一番上へ - + Move selected set up 選択中のセットを上へ - + Move selected set down 選択中のセットを下へ - + Move selected set to the bottom 選択したセットを一番下へ - - Search by set name, code, or type - セット名、略称、タイプで検索 - - - + Default order 初期順に戻す - + Restore original art priority order 元のアートの優先順位を復元する - + Enable all sets すべてのセットを有効 - + Disable all sets すべてのセットを無効 - + Enable selected set(s) 選択したセットを有効 - + Disable selected set(s) 選択したセットを無効 - + Deck Editor デッキエディター - + Use CTRL+A to select all sets in the view. Ctrl + A キーですべてのセットを選択します。 - + Only cards in enabled sets will appear in the card list of the deck editor. 有効にしたセットのカードのみがデッキエディタのカードリストに表示されます。 - + Image priority is decided in the following order: 画像の優先順位は、次の順序で決定されます: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki 最優先はCUSTOMフォルダ(%1)、以降はこのダイアログで有効化されたセットの並び順 - + Include cards rebalanced for Alchemy [requires restart] - + Card Art カードアート - + + Search by set name, code, type, or release date + + + + How to use custom card art カスタムカードアートの使い方 - + Hints ヒント - + Note 注意 - + Sorting by column allows you to find a set while not changing set priority. 列でソートすると、セットの優先度設定を変えずにセットを探すことができます。 - + To enable ordering again, click the column header until this message disappears. 優先度設定を行うには、このメッセージが消えるまで同じ列見出しをクリックしてください。 - + Use the current sorting as the set priority instead 現在のソートを優先度設定として使用する - + Sorts the set priority using the same column 同じ列を使用して優先度をソートする - + Manage sets セットを管理 @@ -11155,93 +14737,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped グループなし - + Group by Type タイプ別グループ - + Group by Mana Value マナ・コスト別グループ - + Group by Color 色別グループ - + Unsorted 順不同 - + Sort by Name 名前順 - + Sort by Type タイプ順 - + Sort by Mana Cost マナ・コスト順 - + Sort by Colors 色順 - + Sort by P/T P/T順 - + Sort by Set セット順 - + shuffle when closing 閉じる時に切り直す - + pile view タイプ別に重ねて表示 - - i18n - - - English - 日本語 (Japanese) - - main - + Connect on startup 起動時に接続 - + Debug to file @@ -12025,6 +15599,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard 墓地 + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_ja.ts b/oracle/translations/oracle_ja.ts index a437db796..6086ceb74 100644 --- a/oracle/translations/oracle_ja.ts +++ b/oracle/translations/oracle_ja.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction はじめに - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. このウィザードでは、Cockatriceで使用されるカードやトークン、セットのリストをインポートします。 - + Interface language: インターフェース言語: - + Version: バージョン: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection ソース選択 - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. セットとカードのリストの互換性のあるソースを指定してください。ダウンロードするURLアドレスを指定するか、コンピューターから既存のファイルを使用できます。 - + Download URL: ダウンロードURL: - + Local file: ローカルファイル: - + Restore default URL デフォルトのURLを復元 - + Choose file... ファイルを選択... - + Load sets file カードセットファイルを開く - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error エラー - + The provided URL is not valid. 指定されたURLは無効です。 - - Downloading (0MB) - ダウンロード中 (0MB) - - - + Please choose a file. ファイルを選択してください。 - + Cannot open file '%1'. '%1'を開けませんでした。 - - Downloading (%1MB) - ダウンロード中 (%1MB) - - - + Network error: %1. ネットワークエラー: %1。 - + Parsing file ファイルの解析 - + Xz extraction failed. Xz展開に失敗。 - + Sorry, this version of Oracle does not support xz compressed files. このバージョンのOracleはxz圧縮ファイルをサポートしていません。 - + Failed to open Zip archive: %1. ZIPアーカイブの展開に失敗: %1。 - + Zip extraction failed: the Zip archive doesn't contain exactly one file. ZIP展開に失敗:Zipアーカイブに含まれるファイルが1つだけではありません。 - + Zip extraction failed: %1. ZIP展開に失敗: %1。 - + Sorry, this version of Oracle does not support zipped files. 申し訳ありませんが現バージョンのOracleはzip形式のファイルをサポートしていません。 - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? 代わりに非圧縮ファイルをダウンロードしますか? - + + The file was retrieved successfully, but it does not contain any sets data. ファイルは正常に取得されましたが、カードセットのデータが含まれていませんでした。 + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database スポイラーデータベースを保存 - + XML; spoiler database (*.xml) XML; スポイラーデータベース (*.xml) - + spoiler - + Spoilers import スポイラーインポート - + Please specify a compatible source for spoiler data. 互換性のあるスポイラーデータのソースを指定してください。 - + Download URL: ダウンロードURL: - + Local file: - + Restore default URL デフォルトのURLを復元 - + Choose file... - + The spoiler database will be saved at the following location: スポイラーデータベースは、以下の場所に保存されます: - + Save to a custom path (not recommended) 別のパスに保存(非推奨) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database トークンデータベースを保存 - + XML; token database (*.xml) XML; トークンデータベース (*.xml) - + tokens - + Tokens import トークンのインポート - + Please specify a compatible source for token data. トークンデータのソースを選択してください。 - + Download URL: ダウンロードURL: - + Local file: - + Restore default URL デフォルトのURLを復元 - + Choose file... - + The token database will be saved at the following location: トークンデータベースは以下の場所に保存されます。 - + Save to a custom path (not recommended) 別のパスに保存(非推奨) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens ダミーセットを含むトークン @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle Importer - オラクル・インポーター @@ -292,22 +306,22 @@ OutroPage - + Finished 完了しました! - + The wizard has finished. ウィザードが完了しました。 - + You can now start using Cockatrice with the newly updated cards. Cockatriceで新しく更新されたカードを使うことが出来ます。 - + If the card databases don't reload automatically, restart the Cockatrice client. カードデータベースが自動的に再読込されない場合は、Cockatriceを再起動して下さい。 @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error エラー - + + No set has been imported. セットはインポートされませんでした。 - + Sets imported カードセットインポート - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: 次のセットが見つかりました: - + Press "Save" to store the imported cards in the Cockatrice database. ”保存”をクリックするとインポートしたカードをCockatriceデータベースに保存します。 - + The card database will be saved at the following location: カードデータベースは以下の場所に保存されます: - + Save to a custom path (not recommended) 別のパスに保存(非推奨) - + &Save 保存 - + + Import finished: %1 cards. %1枚のカードがインポートされました。 - + + %1: %2 cards imported %1: %2枚のカードがインポートされました。 - + Save card database カードデータベースを保存 - + XML; card database (*.xml) XML; card database (*.xml) - + The file could not be saved to %1 %1に保存できませんでした。 @@ -414,8 +431,9 @@ - Downloading (0MB) - ダウンロード中 (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,7 +605,7 @@ i18n - + English 日本語 (Japanese) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode スポイラーモードでのみ起動 - + Run in no-confirm background mode From 1756501ded825a1645d4ffd0ba4a6e3914c0d82c Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:26:12 +0200 Subject: [PATCH 055/247] Updates for project Cockatrice and language it (#7554) * Translate oracle_en@source.ts in it [Manual Sync] 93% of minimum 5% translated source file: 'oracle_en@source.ts' on 'it'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in it [Manual Sync] 67% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'it'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_it.ts | 7484 ++++++++++++++++------ oracle/translations/oracle_it.ts | 204 +- 2 files changed, 5645 insertions(+), 2043 deletions(-) diff --git a/cockatrice/translations/cockatrice_it.ts b/cockatrice/translations/cockatrice_it.ts index e370878b9..6054c5683 100644 --- a/cockatrice/translations/cockatrice_it.ts +++ b/cockatrice/translations/cockatrice_it.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Imposta &contatore... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Imposta contatore - + New value for counter '%1': Nuovo valore per il contatore '%1': @@ -36,66 +36,115 @@ AbstractTabDeckEditor - + Open in new tab Apri in una nuova scheda - + Are you sure? Sei sicuro? - + The decklist has been modified. Do you want to save the changes? La lista del mazzo è stata modificata. Vuoi salvare i cambiamenti? - - - - - - + + + + + + Error Errore - + Could not open deck at %1 Impossibile aprire il mazzo a %1 - + Could not save remote deck Impossibile salvare mazzo remoto - - + + The deck could not be saved. Please check that the directory is writable and try again. Il mazzo non può essere salvato. Controlla se la cartella è valida e prova ancora. - + Save deck Salva mazzo - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. Il mazzo non può essere salvato. - + There are no cards in your deck to be exported Non ci sono carte da esportare nel tuo mazzo + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Controlla se la cartella è valida e prova ancora. AdminNotesDialog - + Update Notes Aggiorna note - + Admin Notes for %1 Note amministratori su %1 @@ -132,224 +181,310 @@ Controlla se la cartella è valida e prova ancora. Tokens - + Pedine AppearanceSettingsPage - + seconds secondi - + Error Errore - + Could not create themes directory at '%1'. Impossibile creare la cartella dei temi in '%1'. - + Theme settings Impostazioni temi - + Current theme: Tema attuale: - + Open themes folder Apri cartella temi - + Home tab background source: Sorgente dello sfondo della Home: - + Home tab background shuffle frequency: Frequenza di modifica dello sfondo della Home: - + Disabled Disattivato - + Menu settings Impostazioni menù - + Show keyboard shortcuts in right-click menus Mostra scorciatoie da tastiera nel menù del tasto destro del mouse - + Show game filter toolbar above list in room tab Mostra barra di filtro sopra la lista delle partite - + Card rendering Visualizzazione delle carte - + Display card names on cards having a picture Visualizza nome delle carte sopra le immagini - + Auto-Rotate cards with sideways layout Ruota automaticamente carte con disposizione orizzontale - + Override all card art with personal set preference (Pre-ProviderID change behavior) Determina l'illustrazione della carta attraverso la priorità dei set (come prima dell'implementazione del ProviderID) - + Light - + Chiaro - + Dark - + Scuro - + + System + Sistema + + + + Show all playmats - + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: + Palette del tema attivo: + + + + Active theme style: - + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Modifica palette del tema - + Home tab settings - + Impostazioni tab Home - + Display card name of background in bottom right + Mostra nome della carta in basso a destra sullo sfondo + + + + Dim the home tab background - + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector Nel selettore di stampa, mostra per primi i set delle carte che il mazzo contiene - + Scale cards on mouse over Ingrandisci la carta sotto il mouse - + Use rounded card corners Usa i bordi delle carte arrotondati - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand Sovrapposizione % minima delle carte in pila e nella mano verticale: - + Maximum initial height for card view window: Altezza iniziale massima per la finestra di visualizzazione delle carte: - - + + rows file - + Maximum expanded height for card view window: Altezza massima per la finestra di visualizzazione delle carte: - + Card counters Segnalini della carta - + Counter %1 Segnalino %1 - + Hand layout Disposizione della mano - + Display hand horizontally (wastes space) Disponi la mano orizzontalmente (spreca spazio) - + Enable left justification Allinea a sinistra - + Table grid layout Disposizione delle aree di gioco - + Invert vertical coordinate Inverti disposizione verticale - + Minimum player count for multi-column layout: Numero di giocatori minimo per disposizione multicolonna: - + Maximum font size for information displayed on cards: Dimensione massima carattere per le informazioni mostrate sulle carte: @@ -357,12 +492,12 @@ Controlla se la cartella è valida e prova ancora. ArchidektApiResponseDeckDisplayWidget - + Back to results Torna ai risultati - + Open Deck in Deck Editor Apri mazzo nell'editor dei mazzi @@ -388,112 +523,112 @@ Controlla se la cartella è valida e prova ancora. BanDialog - + ban &user name Banna &user name - + ban &IP address Banna indirizzo &IP - + ban client I&D Banna client I&D - + Ban type Tipo di ban - + &permanent ban Ban &permanente - + &temporary ban Ban &temporaneo - + &Days: &Giorni: - + &Hours: &Ore: - + &Minutes: &Minuti: - + Duration of the ban Durata del ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Per favore inserisci la ragione del ban. Questa è visibile solo ai moderatori e non alla persona bannata. - + Please enter the reason for the ban that will be visible to the banned person. Per favore inserisci la ragione del ban che sarà visibile alla persona bannata. - + Redact all messages from this user in all rooms Elimina tutti i messaggi di questo utente in tutte le stanze - + &OK &OK - + &Cancel &Annulla - + Ban user from server Banna utente dal server - - - - + + + + Error Errore - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Seleziona un nome, un IP, un client ID o una combinazione di questi per impostare un ban. - + You must have a value in the name ban when selecting the name ban checkbox. Devi inserire un valore nel campo nome quando il ban per nome è selezionato. - + You must have a value in the ip ban when selecting the ip ban checkbox. Devi inserire un valore nel campo ip quando il ban per ip è selezionato. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Devi inserire un valore nel campo clientid quando il ban per clientid è selezionato. @@ -501,22 +636,22 @@ Questa è visibile solo ai moderatori e non alla persona bannata. BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Nessuna risposta ricevuta dal server degli aggiornamenti. - + Invalid reply received from the release update server. Risposta non valida ricevuta dal server degli aggiornamenti. - + No reply received from the file update server. Nessuna risposta ricevuta dal server degli aggiornamenti. @@ -524,7 +659,20 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -532,22 +680,22 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardArtRulesModel - + Card - + Carta - + ProviderId - + ProviderId - + Mode - + Reason @@ -555,65 +703,209 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardDatabaseModel - + Name Nome - + Sets Set - + Mana cost Costo - + Card type Tipo - + P/T F/C - + Color(s) Colore + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Aggiungi al mazzo - + Add to Sideboard - + Aggiungi alla Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -723,22 +1015,22 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardInfoFrameWidget - + Image Immagine - + Description Descrizione - + Both Entrambi - + View transformation Visualizza trasformazione @@ -746,22 +1038,22 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardInfoPictureWidget - + View related cards Guarda carte correlate - + Add card to deck Aggiungi la carta al &mazzo - + Mainboard Mazzo - + Sideboard Sideboard @@ -769,27 +1061,27 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardInfoTextWidget - + Name: Nome: - + Set: Set: - + Collector Number: Numero di collezione: - + Related cards: Carte correlate: - + Unknown card: Carta sconosciuta: @@ -797,129 +1089,129 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardMenu - + Re&veal to... Ri&vela a... - + &All players &Tutti i giocatori - + View related cards Guarda carte correlate - + Token: Pedina: - + All tokens Tutte le pedine - + &Select All &Seleziona tutto - + S&elect Row S&eleziona fila - + S&elect Column S&eleziona colonna - + &Play &Gioca - + &Hide &Nascondi - + Play &Face Down Gioca a &faccia in giù - + &Tap / Untap Turn sideways or back again &TAPpa/STAPpa - + Skip &untapping Salta lo &STAP - + T&urn Over Turn face up/face down Capovolgi - + &Peek at card face &Sbircia la faccia della carta - + &Clone &Copia - + Attac&h to card... Asse&gna alla carta... - + Unattac&h Tog&li - + &Draw arrow... &Disegna una freccia... - + &Set annotation... &Imposta note... - + Reduce life by power - + Ca&rd counters Segnalini delle ca&rte - + &Add counter (%1) &Aggiungi segnalino (%1) - + &Remove counter (%1) &Rimuovi segnalino (%1) - + &Set counters (%1)... Imposta &segnalini (%1)... @@ -940,7 +1232,7 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardSizeWidget - + Card Size Dimensioni carte @@ -1080,6 +1372,24 @@ Questa è visibile solo ai moderatori e non alla persona bannata. la zona personalizzata '%2' di %1 + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1109,280 +1419,382 @@ Questa è visibile solo ai moderatori e non alla persona bannata. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1390,113 +1802,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1546,7 +1958,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database Database carte @@ -1562,17 +1974,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Cerca per nome della carta (o espressioni di ricerca) - + Add card to &maindeck Aggiungi carta al &mazzo - + Add card to &sideboard Aggiungi carta alla &sideboard @@ -1580,97 +1992,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... Caricamento database... - + Banner Card Carta copertina - + Main Type Tipo - + Mana Cost Costo di mana - + Colors Colori - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing Seleziona stampa - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Mazzo - + Deck &name: &Nome mazzo: - + Banner Card/Tags Visibility Settings Impostazioni carta copertina/etichette - + Show banner card selection menu Visualizza menu di selezione carta copertina - + Show tags selection menu Visualizza menu di selezione etichette - + &Comments: &Commenti: - + Group by: Raggruppa per: - + Format: Formato: - + Hash: Hash: - + &Increment number &Aumenta il numero - + &Decrement number &Diminuisci il numero - + &Remove row &Rimuovi carta - + Swap card to/from sideboard Sposta carta in maindeck/sideboard @@ -1678,17 +2151,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Filtri - + &Clear all filters &Elimina tutti i filtri - + Delete selected Elimina filtro @@ -1696,114 +2169,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor &Editor dei mazzi - + &New deck &Nuovo mazzo - + &Load deck... &Carica mazzo... - + Load recent deck... Carica mazzo recente... - + Clear Svuota - + &Save deck &Salva mazzo - + Save deck &as... Salva mazzo &con nome... - + + Share deck... + + + + Load deck from cl&ipboard... Carica mazzo dagli app&unti... - + Edit deck in clipboard Modifica mazzo negli appunti - - + + Annotated Con annotazioni - - + + Not Annotated Senza annotazioni - + Save deck to clipboard Salva il mazzo negli appunti - + Annotated (No set info) Con annotazioni (senza info set) - + Not Annotated (No set info) Senza annotazioni (senza info set) - + &Print deck... Stam&pa mazzo... - + Load deck from online service... Carica mazzo dal servizio online... - + &Send deck to online service &Invia mazzo al servizio online - + Create decklist (decklist.org) Crea una decklist (decklist.org) - + Create decklist (decklist.xyz) Crea una decklist (decklist.xyz) - + Analyze deck (deckstats.net) Analizza mazzo (deckstats.net) - + Analyze deck (tappedout.net) Analizza mazzo (tappedout.net) - + &Close &Chiudi @@ -1811,7 +2289,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector Selettore di stampa @@ -1819,110 +2297,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Aggiorna Spoiler - + Success Fatto - + Download URLs have been reset. Gli indirizzi di download sono stati resettati. - + Add URL Aggiungi indirizzo - - + + URL: Indirizzo: - - + + Edit URL Modifica indirizzo: - + Add New URL Aggiungi indirizzo URL - + Remove URL Elimina indirizzo URL - + Updating... Aggiornando... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Scegli il percorso - + URL Download Priority Ordine di priorità degli indirizzi - + Spoilers Spoiler - + Download Spoilers Automatically Scarica spoiler automaticamente - + Spoiler Location: Indirizzo spoiler: - + Last Change Ultima modifica - + Spoilers download automatically on launch Scarica spoiler automaticamente all'avvio - + Press the button to manually update without relaunching Premi il pulsante per aggiornare manualmente senza riavviare - + Do not close settings until manual update is complete Non chiudere le impostazioni fino a che l'aggiornamento manuale sia completato - + Download card pictures on the fly Scarica immagini delle carte in tempo reale - + How to add a custom URL Come aggiungere indirizzi personalizzati - + Reset Download URLs Resetta indirizzi di download @@ -1930,32 +2450,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo Annulla - + Redo Ripeti - + Undo/Redo history Annulla/Ripeti Storico - + Click on an entry to revert to that point in the history. Clicca su una riga per ritornare a quel punto nello storico delle modifiche. - + [redo] [ripeti] - + [undo] [annulla] @@ -1963,27 +2483,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Quantità - + Set Set - + Number Numero - + Provider ID ID Provider - + Card Carta @@ -2004,17 +2524,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Modalità: Corrispondenza esatta - + Mode: Includes Modalità: Include - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) Filtraggio identità di colore (utilizzabile con connettivi logici AND/OR/NOT) @@ -2022,7 +2547,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Imposta etichette... @@ -2030,62 +2555,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Gestore etichette del mazzo - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: Gestisci le etichette del mazzo. Assegna o rimuovi etichette, oppure creane di nuove. - + Add a new tag (e.g., Aggro️) Aggiungi una nuova etichetta (Es.: Aggro️) - + Add Tag Aggiungi etichetta - + Filter tags... Filtra etichette... - + OK Conferma - + Edit default tags Modifica etichette - + Cancel Annulla - + Invalid Input Input non valido - + Tag name cannot be empty! Il nome dell'etichetta non può essere vuoto! - + Duplicate Tag Duplica Etichetta - + This tag already exists. Questa etichetta esiste già. @@ -2093,94 +2618,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card Carta copertina - + Open in deck editor Apri nell'editor dei mazzi - + + Share deck... + + + + Edit Tags Modifica etichette - + Rename Deck Rinomina mazzo - + Save Deck to Clipboard Salva il mazzo negli appunti - + Annotated Con annotazioni - + Annotated (No set info) Con annotazioni (senza info set) - + Not Annotated Senza annotazioni - + Not Annotated (No set info) Senza annotazioni (senza info set) - + Rename File Rinomina file - + Delete File Elimina file - + Set Banner Card Imposta carta copertina - - + + New name: Nuovo nome: - - + + Rename deck + + + + + Rename file + + + + + Error Errore - + Rename failed Rinomina non riuscita - + Delete file Elimina file - + Are you sure you want to delete the selected file? Vuoi davvero eliminare il file selezionato? - + Delete failed Eliminazione non riuscita @@ -2188,57 +2728,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" Rinominato mazzo in "%1" da "%2" - + Updated comments (was %1 chars, now %2 chars) Commenti aggiornati (prima %1 caratteri, ora %2 caratteri) - + Set banner card to %1 (%2) Impostata carta copertina a %1 (%2) - + + Set playmat to %1 + + + + Tags changed Etichette modificate - + Set format to %1 Formato impostato a %1 - + Added (%1): %2 (%3) %4 Aggiunto (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Spostato in %1 1 × "%2" (%3) - + Removed "%1" (all copies) Rimosso "%1" (tutte le copie) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Aggiunto - + Removed Rimosso @@ -2260,88 +2845,116 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Carica mazzo... - + Load remote deck... Carica mazzo remoto... - + Load from clipboard... Carica dagli appunti... - + Load from website... Carica dal sito... - + Unload deck Deseleziona mazzo - + Ready to start Pronto ad iniziare - + Force start Forza avvio - + Sideboard unlocked Sideboard sbloccata - + Sideboard locked Sideboard bloccata - - + + Error Errore - + The selected file could not be loaded. I file selezionati non posso essere caricati. - + Deck is greater than maximum file size. Il mazzo è più grande della dimensione massima del file consentita. - + Are you sure you want to force start? This will kick all non-ready players from the game. Sicuro di voler forzare l'avvio? Ciò espellerà dalla partita tutti i giocatori che non sono pronti. - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion Conversione formato mazzo - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? @@ -2350,7 +2963,7 @@ Le etichette possono essere aggiunte solo a mazzi in formato .cod. Vuoi convertire il mazzo al formato .cod? - + Remember and automatically apply choice in the future Ricorda e applica automaticamente la scelta in futuro @@ -2363,143 +2976,143 @@ Vuoi convertire il mazzo al formato .cod? Scaricamento... - + Known Hosts Host Conosciuti - + Delete the currently selected saved server Elimina il server salvato attualmente selezionato - + Refresh the server list with known public servers Aggiorna la lista dei server con server pubblici - + New Host Nuovo indirizzo - + Name: Nome: - + &Host: &Host: - + &Port: &Porta: - + Player &name: &Nome giocatore: - + P&assword: P&assword: - + &Save password &Salva password - + A&uto connect Connetti a&utomaticamente - + Automatically connect to the most recent login when Cockatrice opens Connetti automaticamente all'ultimo server utilizzato all'avvio di Cockatrice - + If you have any trouble connecting or registering then contact the server staff for help! Se hai problemi a connetterti o registrarti contatta lo staff del server per aiuto! - - + + Webpage Sito web - + Reset Password Resetta Password - + Forgot password? Hai dimenticato la password? - + &Connect &Connetti - + Server Server - + Login Login - + Server Contact Contatto server - + Connect to Server Connetti al server - + Server URL URL Server - + Communication Port Numero porta - + Unique Server Name Nome univoco del server - + Connection Warning Avviso di connessione - + You need to name your new connection profile. È necessario impostare un nome per il nuovo profilo di connessione. - + Connect Warning Problema di connessione - + The player name can't be empty. Il nome del giocatore non può essere vuoto. @@ -2507,122 +3120,122 @@ Vuoi convertire il mazzo al formato .cod? DlgCreateGame - + Re&member settings Ricorda impostazioni - + &Description: &Descrizione: - + P&layers: G&iocatori: - + General Generali - + Game type Formato di gioco - + &Password: &Password: - + Only &buddies can join Solo gli &amici possono entrare - + Only &registered users can join Solo &utenti registrati possono entrare - + Joining restrictions Restrizioni d'ingresso - + &Spectators can watch &Gli spettatori possono osservare - + Spectators &need a password to watch Gli spettatori &necessitano di password - + Spectators can &chat Gli spettatori possono &chattare - + Spectators can see &hands Gli spettatori vedono le mani - + Create game as spectator Crea partita come spettatore - + Spectators Spettatori - + Starting life total: Punti vita iniziali: - + Open decklists in lobby Apri mazzi nella lobby - + Create game as judge Crea partita come arbitro - + Game setup options Configurazione partita - + &Clear Pulisci - + Create game Crea partita - + Game information Informazioni partita - + Error Errore - + Server error. Errore del server. @@ -2630,97 +3243,97 @@ Vuoi convertire il mazzo al formato .cod? DlgCreateToken - + &Name: &Nome: - + Token Pedina - + C&olor: C&olore: - + white bianco - + blue blu - + black nero - + red rosso - + green verde - + multicolor multicolore - + colorless incolore - + &P/T: &F/C: - + &Annotation: &Note: - + &Destroy token when it leaves the table &Elimina la pedina quando lascia il campo - + Create face-down (Only hides name) Crea a faccia in giù (nasconde solo il nome) - + Token data Dati pedina - + Show &all tokens Mostra &tutte le pedine - + Show tokens from this &deck Mostra pedine di questo &mazzo - + Choose token from list Scegli pedina dalla lista - + Create token Crea pedina @@ -2728,53 +3341,53 @@ Vuoi convertire il mazzo al formato .cod? DlgDefaultTagsEditor - + Edit Tags Modifica etichette - + Add Aggiungi - + Confirm Conferma - + Cancel Annulla - + Enter a tag and press Enter Scrivi il nome dell'etichetta e premi Invio - - + + ✖ ✖ - + Invalid Input Input non valido - + Tag name cannot be empty! Il nome dell'etichetta non può essere vuoto! - + Duplicate Tag Duplica Etichetta - + This tag already exists. Questa etichetta esiste già. @@ -2783,7 +3396,7 @@ Vuoi convertire il mazzo al formato .cod? DlgEditAvatar - + No image chosen. Nessuna immagine selezionata. @@ -2795,27 +3408,32 @@ To remove your current avatar, confirm without choosing a new image. Per rimuovere il tuo avatar attuale, conferma senza scegliere una nuova immagine. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Cerca... - + Change avatar Cambia avatar - + Open Image Apri immagine - + Image Files (*.png *.jpg *.bmp) File immagine (*.png *.jpg *.bmp) - + Invalid image chosen. Immagine selezionata non valida. @@ -2841,38 +3459,38 @@ Per rimuovere il tuo avatar attuale, conferma senza scegliere una nuova immagine DlgEditPassword - + Old password: Vecchia password: - + New password: Nuova password: - + Confirm new password: Conferma nuova password: - + Change password Cambia password - - + + Error Errore - + Your password is too short. La nuova password è troppo corta. - + The new passwords don't match. Le password non coincidono. @@ -2880,93 +3498,93 @@ Per rimuovere il tuo avatar attuale, conferma senza scegliere una nuova immagine DlgEditTokens - + &Name: &Nome: - + C&olor: C&olore: - + white bianco - + blue blu - + black nero - + red rosso - + green verde - + multicolor multicolore - + colorless incolore - + &P/T: &F/C: - + &Annotation: &Note: - + Token data Dati pedina - - + + Add token Aggiungi pedina - + Remove token Rimuovi pedina - + Edit custom tokens Modifica pedine personalizzate - + Please enter the name of the token: Inserisci il nome della pedina: - + Error Errore - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Il nome scelto è in conflitto con una carta o pedina esistente. @@ -3045,8 +3663,9 @@ Assicurati di abilitare il set "Pedine" nella finestra "Gestisci - Hide games that have started - Nascondi partite iniziate + Hide started games + Hide games that have started + @@ -3081,8 +3700,9 @@ Assicurati di abilitare il set "Pedine" nella finestra "Gestisci - &Creator name: - Nome &creatore: + &Host name: + &Creator name: + @@ -3148,52 +3768,52 @@ Assicurati di abilitare il set "Pedine" nella finestra "Gestisci DlgForgotPasswordChallenge - + Reset Password Challenge Warning Avviso di reimpostazione della password di verifica della sfida - + A problem has occurred. Please try to request a new password again. Si è verificato un problema. Prova a richiedere di nuovo una nuova password. - + Enter the information of the server and the account you'd like to request a new password for. Inserisci le informazioni del server e dell'account per cui desideri richiedere una nuova password. - + &Host: &Host: - + &Port: &Porta: - + Player &name: &Nome giocatore: - + Email: Email: - + Reset Password Challenge Reimposta password sfida - + Reset Password Challenge Error Errore di reimpostazione della password di verifica della sfida - + The email address can't be empty. L'indirizzo email non può essere vuoto. @@ -3201,37 +3821,37 @@ Assicurati di abilitare il set "Pedine" nella finestra "Gestisci DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Inserisci le informazioni del server per il quale desideri richiedere una nuova password. - + &Host: &Host: - + &Port: &Porta: - + Player &name: &Nome giocatore: - + Reset Password Request Richiesta di reimpostazione della password - + Reset Password Error Errore di ripristino della password - + The player name can't be empty. Il nome del giocatore non può essere vuoto. @@ -3239,94 +3859,127 @@ Assicurati di abilitare il set "Pedine" nella finestra "Gestisci DlgForgotPasswordReset - + Reset Password Warning Avviso di reimpostazione della password - + A problem has occurred. Please try to request a new password again. Si è verificato un problema. Prova a richiedere di nuovo una nuova password. - + Enter the received token and the new password in order to set your new password. Inserisci il token ricevuto e la nuova password per impostare la tua nuova password. - + &Host: &Host: - + &Port: &Porta: - + Player &name: &Nome giocatore: - + Token: Token: - - + + New Password: Nuova password: - + Reset Password Resetta la password - + The player name can't be empty. Il nome del giocatore non può essere vuoto. - - - - + + + + Reset Password Error Errore di ripristino della password - + The token can't be empty. Il token non può essere vuoto. - + The new password can't be empty. La nuova password non può essere vuota. - + Error Errore - + Your password is too short. La tua password è troppo corta. - + The passwords do not match. Le password non coincidono. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck Carica mazzo @@ -3418,166 +4071,567 @@ https://tappedout.net/mtg-decks/nome-del-tuo-mazzo/ DlgLocalGameOptions - + Players: Giocatori: - + General Generale - + Starting life total: Punti vita iniziali: - + Game setup options Configurazione partita - + Remember settings Ricorda impostazioni - + Local game options Configurazione partita locale + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Nome della carta (o ricerca espressioni): - + Number of hits: Numero di risultati: - + Auto play hits Riproduzione automatica dei successi - + Put top cards on stack until... Metti le carte in cima alla pila fino a... - + No cards matching the search expression exists in the card database. Proceed anyways? Non esiste alcuna carta corrispondente agli estremi di ricerca nel database. Procedere comunque? - + Cockatrice Cockatrice - + Invalid filter Filtro non valido + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Inserisci le tue informazioni e le informazioni del server a cui desideri registrarti. La tua email verrà utilizzata per verificare il tuo account. - + &Host: &Host: - + &Port: &Porta: - + Player &name: &Nome giocatore: - + P&assword: P&assword: - + Password (again): Password (conferma): - + Email: Email: - + Email (again): Email (conferma): - + Country: Stato: - + Undefined Non definito - + Real name: Nome reale: - + Register to server Registrati sul server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Avviso Registrazione - + Your password is too short. La tua password è troppo corta. - + Your passwords do not match, please try again. Le password non combaciano, riprova. - + Your email addresses do not match, please try again. Gli indirizzi email non combaciano, riprova. - + The player name can't be empty. Il nome del giocatore non può essere vuoto. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3643,46 +4697,12 @@ La tua email verrà utilizzata per verificare il tuo account. DlgSettings - + Unknown Error loading card database Errore sconosciuto durante il caricamento del database delle carte - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Il tuo database delle carte non è valido. - -Cockatrice non può funzionare correttamente con un database delle carte non valido. - -Ti consigliamo di avviare oracle per aggiornare il tuo database delle carte. - -Vuoi modificare le impostazioni della posizione del database della carte? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Il tuo database delle carte è troppo vecchio. - -Questo può creare problemi nel caricamento dell informazioni e delle immagini delle carte. - -Ti consigliamo di avviare oracle per aggiornare il tuo database delle carte. - -Vuoi modificare le impostazioni della posizione del database della carte? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3694,7 +4714,7 @@ Per favore crea un ticket di assistenza su https://github.com/Cockatrice/Cockatr Desideri modificare l'impostazione della posizione del database? - + File Error loading your card database. Would you like to change your database location setting? @@ -3703,7 +4723,7 @@ Would you like to change your database location setting? Vuoi modificare le impostazioni della posizione del database della carte? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3712,7 +4732,7 @@ Would you like to change your database location setting? Vuoi modificare le impostazioni della posizione del database della carte? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3725,77 +4745,208 @@ https://github.com/Cockatrice/Cockatrice/issues Desideri modificare l'impostazione della posizione del database? - - - + + + Error Errore - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Il percorso della cartella del mazzo non è valido. Vuoi tornare in dietro e impostare il percorso corretto? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Il percorso della cartella delle immagini delle carte è invilido. Vuoi tornare indietro e impostare il percorso corretto? - + Settings Impostazioni - + + Search settings... + + + + + OK + + + + General Generale - + Appearance Aspetto - + User Interface Interfaccia - + Storage - + Card Sources Immagini carte - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Suoni - + Shortcuts Scorciatoie + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check Controllo aggiornamenti carte - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. @@ -3804,27 +4955,27 @@ Seleziona la modalità di esecuzione dell'aggiornamento. Potrai comunque cambiarla in seguito dalle impostazioni, nella scheda 'Generale'. - + Run in foreground Esegui in primo piano - + Run in background Esegui in background - + Run in background and always from now on Esegui in background da ora in poi - + Don't prompt again and don't run Non eseguire e non chiedere più - + Don't run this time Non eseguire stavolta @@ -3832,17 +4983,17 @@ Potrai comunque cambiarla in seguito dalle impostazioni, nella scheda 'Gene DlgTipOfTheDay - + Next Prossimo - + Previous Precedente - + Tip of the Day Suggerimento del giorno @@ -3850,40 +5001,40 @@ Potrai comunque cambiarla in seguito dalle impostazioni, nella scheda 'Gene DlgUpdate - + Current release channel Canale della versione corrente - + Reinstall Reinstalla - + Cancel Download Annulla download - + Open Download Page Apri pagina di download - + Check for Client Updates Verifica la presenza di aggiornamenti del client - - - - + + + + Error Errore - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice non è stato creato con il supporto SSL, quindi gli aggiornamenti non possono essere scaricati automaticamente! @@ -3891,126 +5042,136 @@ Please visit the download page to update manually. Per favore, visitate la pagina di download per aggiornare manualmente. - + Downloading update: %1 Download dell'aggiornamento: %1 - + Checking for updates... Ricerca aggiornamenti... - + Finished checking for updates Controllo aggiornamenti terminato - + No Update Available Nessun aggiornamento disponibile - + Cockatrice is up to date! Cockatrice è aggiornato! - + You are already running the latest version available in the chosen release channel. Hai già la versione più recente nel canale della versione scelto. - + Current version Versione corrente - + Selected release channel Canale della versione selezionato - - + + Update Available Aggiornamento disponibile - - + + A new version of Cockatrice is available! Una nuova versione di Cockatrice è disponibile! - - + + New version Nuova versione - - + + Released Data di rilascio - - + + Changelog Changelog - + Do you want to update now? Vuoi aggiornare adesso? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Purtroppo l'aggiornamento automatico non è riuscito a trovare una versione compatibile. Dovrai scaricare la nuova versione manualmente. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Per favore, controlla la <a href="%1">pagina delle release</a> sul nostro Github e scarica la versione giusta per il tuo sistema. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Errore durante l'aggiornamento - + An error occurred while checking for updates: Errore durante il controllo degli aggiornamenti: - + An error occurred while downloading an update: Errore durante il download di un aggiornamento: - + Installing... Installazione... - + Cockatrice is unable to open the installer. Cockatrice non riesce ad aprire il file di installazione. - + Try to update manually by closing Cockatrice and running the installer. Prova ad aggiornare manualmente chiudendo Cockatrice ed eseguento il file di installazione. - + Download location Percorso download @@ -4018,17 +5179,17 @@ Dovrai scaricare la nuova versione manualmente. DlgViewLog - + Clear log when closing Pulisci la cronologia al riavvio - + Copy to clipboard Copia negli appunti - + Debug Log Registro di debug @@ -4266,7 +5427,7 @@ Dovrai scaricare la nuova versione manualmente. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: Sale: @@ -4274,7 +5435,7 @@ Dovrai scaricare la nuova versione manualmente. FilterBuilder - + Type your filter here Scrivi il filtro qui @@ -4282,45 +5443,88 @@ Dovrai scaricare la nuova versione manualmente. FilterDisplayWidget - + Confirm Delete Conferma eliminazione - + Are you sure you want to delete the filter '%1'? Vuoi davvero eliminare il filtro '%1'? - + Delete Failed Eliminazione non riuscita - + Failed to delete filter '%1'. Impossibile eliminare il filtro '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator espulso da proprietario del gioco o moderatore - + player left the game il giocatore ha lasciato la partita - + player disconnected from server il giocatore si è disconnesso dal server - + reason unknown ragione sconosciuta @@ -4328,140 +5532,156 @@ Dovrai scaricare la nuova versione manualmente. GameSelector - - - - - - - - - + + + + + + + + + Error Errore - + Please join the appropriate room first. Si prega di entrare prima in una stanza adeguata. - + Wrong password. Password errata. - + Spectators are not allowed in this game. Spettatori non ammessi in questa partita. - + The game is already full. La partita è piena. - + The game does not exist any more. Questa partita non esiste più. - + This game is only open to registered users. Questa partita è solo per utenti registrati. - + This game is only open to its creator's buddies. Questa stanza è aperta solo agli amici del suo creatore. - + You are being ignored by the creator of this game. Sei stato ingnorato dal creatore di questa partita. - + Join Game Entra nella partita - + Spectate Game Osserva partita - + Game Information Informazioni partita - + + Cop&y game link + + + + Join Game as Judge Unisciti a una partita come arbitro - + Spectate Game as Judge Osserva partita come arbitro - + + Join game Entra nella partita - - Password: - Password: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Si prega di entrare prima nella rispettiva stanza. - + &Filter games &Filtra partite - + C&lear filter E&limina filtri - + C&reate Cr&ea - + &Join &Entra - + Join as judge Unisciti come arbitro - + J&oin as spectator Entra c&ome spettatore - + Join as judge spectator Unisciti come arbitro spettatore - + Games shown: %1 / %2 Partite mostrate: %1 / %2 - + Games Partite @@ -4502,105 +5722,105 @@ Dovrai scaricare la nuova versione manualmente. GamesModel - + >1 day >1 giorno - + %1%2 hr short age in hours %1%2 ora%1%2 ore%1%2 ora(e) - + new nuovo - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password password - + buddies only solo amici - + reg. users only solo utenti registrati - + open decklists Apri impostazioni - - + + can chat può chattare - + see hands vede mani - + can see hands può vedere mani - + not allowed non ammessi - + Room Stanza - + Age Età - + Description Descrizione - - Creator - Creatore + + Host + - + Type Tipo - + Restrictions Restrizioni - + Players Giocatori - + Spectators Spettatori @@ -4608,158 +5828,275 @@ Dovrai scaricare la nuova versione manualmente. GeneralSettingsPage - + Reset all paths Reimposta tutti i percorsi - + All paths have been reset I percorsi sono stati resettati - - - - - - - + + + + English + Italiano (Italian) + + + + + + + + + Choose path Seleziona il percorso - - Language settings + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> - + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + + Language settings + Impostazioni lingua + + + Language: Lingua: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Destinazioni (non si può personalizzare in modalità portatile) - + Paths Percorsi - + How to help with translations Come aiutare con le traduzioni - + Decks directory: Cartella mazzi: - + Filters directory: Cartella filtri: - + Replays directory: Cartella replay: - + Pictures directory: Cartella immagini: - + Card database: Database carte: - + Custom database directory: Cartella database personalizzata: - + Token database: Database pedine: - + Update channel Canale di aggiornamento: - + Check for client updates on startup Controlla gli aggiornamenti del client all'avvio - + Check for card database updates on startup Controlla gli aggiornamenti delle carte all'avvio - + Don't check Non controllare - + Prompt for update Chiedi se aggiornare - + Always update in the background Aggiorna sempre in background - + Check for card database updates every Controlla gli aggiornamenti delle carte ogni - + days giorni - + Notify if a feature supported by the server is missing in my client Avvisami se una funzionalità supportata dal server manca nel mio programma - + Automatically run Oracle when running a new version of Cockatrice Avvia automaticamente Oracle se Cockatrice è stato aggiornato - + Show tips on startup Mostra suggerimenti all'avvio - + Last update check on %1 (%2 days ago) Ultimo controllo %1 (%2 giorni fa) @@ -4767,47 +6104,47 @@ Dovrai scaricare la nuova versione manualmente. GraveyardMenu - + &Graveyard &Cimitero - + &View graveyard &Guarda il cimitero - + &Move graveyard to... &Muovi cimitero in... - + &Top of library &Cima al grimorio - + &Bottom of library &Fondo al grimorio - + &All players &Tutti i giocatori - + &Hand &Mano - + &Exile &Esilio - + Reveal random card to... Rivela carta casuale a... @@ -4901,59 +6238,353 @@ Dovrai scaricare la nuova versione manualmente. Rivela carta c&asuale a... + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck Crea un nuovo mazzo - + Browse Decks Esplora i mazzi - + Browse Card Database Esplora il database delle carte - + Browse EDHRec Esplora EDHRec - + Browse Archidekt Esplora Archidekt - + View Replays Guarda i replay - + Quit Esci - + Connecting... Connessione in corso... - + Connect Connetti - + Play Gioca + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5171,273 +6802,294 @@ Dovrai scaricare la nuova versione manualmente. MainWindow - - + + Player %1 Giocatore %1 - + Load replay Carica replay - + About Cockatrice Info su Cockatrice - + Version Versione - + Cockatrice Webpage Sito di Cockatrice - + Project Manager: Manager del progetto: - + Past Project Managers: Manager precedenti: - + Developers: Sviluppatori: - + Our Developers I nostri sviluppatori - + Help Develop! Aiutaci nello sviluppo! - + Translators: Traduttori: - + Our Translators I nostri traduttori - + Help Translate! Aiutaci nella traduzione! - + Support: Supporto: - + Report an Issue Segnala un problema - + Troubleshooting Risoluzione dei problemi - + F.A.Q. F.A.Q. - - + + Error Errore - + &Connect... &Connetti... - + &Disconnect &Disconnetti - + Start &local game... Inizia partita in &locale... - + &Watch replay... &Guarda replay... - + &Full screen &Schermo intero - + &Register to server... &Registrati sul server... - + &Restore password... Recupera &password... - + &Settings... &Impostazioni... - + &Exit &Esci - + A&ctions A&zioni - + &Cockatrice &Cockatrice - + C&ard Database &Database carte - + &Manage sets... &Gestisci Espansioni... - + Edit custom &tokens... Modifica pedine personalizzate - + Open custom image folder Apri cartella immagini personalizzate - + Open custom sets folder Apri cartella set personalizzati - + Add custom sets/cards Aggiungi set di carte personalizzato - + Reload card database Ricarica database carte - + Tabs &Schede - + &Help &Aiuto - + &About Cockatrice &Info su Cockatrice - + &Tip of the Day Suggerimento del giorno - + Check for Client Updates Controlla aggiornamenti client - + Check for Card Updates... Controlla aggiornamenti carte... - + Check for Card Updates (Automatic) Controlla aggiornamenti carte in background - + Show Status Bar Mostra barra di stato - + View &Debug Log Vedi Registro di &debug - + Open Settings Folder Apri cartella impostazioni - + + Re-run Onboarding Wizard... + + + + Show/Hide Mostra/Nascondi - + + Sign-in cancelled + + + + New Version Nuova versione - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Congratulazioni per aver aggiornato Cockatrice alla versione %1! Oracle verrà avviato per aggiornare anche il database delle carte. - + Cockatrice installed Cockatrice installata - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Congratulazioni per aver installato Cockatrice %1! Oracle verrà avviato per installare il database delle carte. - + + Are you sure? + Sei sicuro? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Database carte - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5446,29 +7098,29 @@ Vuoi provare ad aggiornare il database adesso? Se è la prima volta che usi Cockatrice o non sei sicuro, scegli "Sì" - - + + Yes Sì - - + + No No - + Open settings Apri impostazioni - + New sets found Nuovi set trovati - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5481,22 +7133,22 @@ Codici dei set: %1 Desideri attivarli? - + Yes, always enable - + View sets Vedi set - + Welcome Benvenuto - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5505,90 +7157,90 @@ Tutti i set nell'archivio delle carte sono stati abilitati. Scopri metodi alternativi per visualizzare i set o disabilitare set ed effetti nella finestra "Gestisci Espansioni". - + Information Informazione - + A card database update is already running. L'aggiornamento delle carte è già in corso. - + Unable to run the card database updater: Impossibile avviare l'aggiornamento delle carte: - + Card database update running. Aggiornamento delle carte in corso. - + Failed to start. The file might be missing, or permissions might be incorrect. Avvio non riuscito. Il file potrebbe essere assente, o i permessi errati. - + The process crashed some time after starting successfully. Il processo si è arrestato in modo anomalo poco dopo essersi avviato con successo. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Richiesta scaduta. Il processo ci ha messo troppo a rispondere. L'ultima funzione waitFor...() è scaduta. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Si è verificato un errore nel cercare di scrivere al processo. Ad esempio, il processo potrebbe non essere in esecuzione, o potrebbe aver chiuso il suo canale di input. - + An error occurred when attempting to read from the process. For example, the process may not be running. Si è verificato un errore nel cercare di leggere dal processo. Ad esempio, il processo potrebbe non essere in esecuzione. - + Unknown error occurred. Si è verificato un errore sconosciuto. - + The card database updater exited with an error: %1 L'aggiornamento del database delle carte è terminato con un errore: %1 - - - - - + + + + + Load sets/cards Carica set di carte - + Selected file cannot be found. Il file selezionato non può essere caricato. - + You can only import XML databases at this time. E' possibile importare solamente database XML. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Il nuovo set di carte è stato aggiunto correttamente. Il database delle carte verrà ricaricato. - + Sets/cards failed to import. Il nuovo set di carte non è stato importato a causa di un errore. @@ -5793,615 +7445,615 @@ Il database delle carte verrà ricaricato. MessageLogWidget - + from play dal campo di battaglia - + from their graveyard dal suo cimitero - + from exile dall'esilio - + from their hand dalla sua mano - + the top card of %1's library la prima carta del grimorio di %1 - + the top card of their library la prima carta del suo grimorio - + from the top of %1's library dalla cima del grimorio di %1 - + from the top of their library dalla cima del suo grimorio - + the bottom card of %1's library l'ultima carta del grimorio di %1 - + the bottom card of their library l'ultima carta del suo grimorio - + from the bottom of %1's library dal fondo del grimorio di %1 - + from the bottom of their library dal fondo del suo grimorio - + from %1's library dal grimorio di %1 - + from their library dal suo grimorio - + from sideboard dalla sideboard - + from the stack dalla pila - + from custom zone '%1' dalla zona personalizzata '%1' - + %1 is now keeping the top card %2 revealed. %1 sta tenendo la prima carta %2 rivelata. - + %1 is not revealing the top card %2 any longer. %1 non sta più rivelando la prima carta %2. - + %1 can now look at top card %2 at any time. %1 può guardare la prima carta %2 della libreria in qualunque momento. - + %1 no longer can look at top card %2 at any time. %1 non può più guardare la prima carta %2 del mazzo in qualunque momento. - + %1 attaches %2 to %3's %4. %1 assegna %2 a %4 di %3. - + %1 has conceded the game. %1 ha concesso la partita. - + %1 has unconceded the game. %1 ha annullato la concessione della partita. - + %1 has restored connection to the game. %1 ha ripristinato il collegamento alla partita. - + %1 has lost connection to the game. %1 ha perso il collegamento alla partita. - + %1 points from their %2 to themselves. %1 disegna una freccia dal suo %2 a sé stesso. - + %1 points from their %2 to %3. %1 disegna una freccia dal suo %2 a %3. - + %1 points from %2's %3 to themselves. %1 disegna una freccia dal %3 di %2 a sé stesso. - + %1 points from %2's %3 to %4. %1 disegna una freccia dal %3 di %2 a %4. - + %1 points from their %2 to their %3. %1 disegna una freccia dal suo %2 al suo %3. - + %1 points from their %2 to %3's %4. %1 disegna una freccia dal suo %2 a %4 di %3. - + %1 points from %2's %3 to their own %4. %1 disegna una freccia da %3 di %2 al suo %4. - + %1 points from %2's %3 to %4's %5. %1 disegna una freccia dal %3 di %2 al %5 di %4. - + %1 creates a face down token. %1 crea una pedina a faccia in giù. - + %1 creates token: %2%3. %1 crea una pedina: %2%3. - + %1 has loaded a deck (%2). %1 ha caricato un mazzo (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 ha caricato un mazzo con %2 carte nella sideboard (%3). - + %1 destroys %2. %1 distrugge %2. - + a card una carta - + %1 gives %2 control over %3. %1 da il controllo di %3 a %2. - + %1 puts %2 into play%3 face down. %1 mette %2 sul campo di battaglia %3 a faccia in giù. - + %1 puts %2 into play%3. %1 mette %2 sul campo di battaglia%3. - + %1 puts %2%3 into their graveyard face down. %1 mette %2%3 nel suo cimitero a faccia in giù. - + %1 puts %2%3 into their graveyard. %1 mette %2 nel suo cimitero%3. - + %1 exiles %2%3 face down. %1 esilia %2%3 a faccia in giù. - + %1 exiles %2%3. %1 esilia %2%3. - + %1 moves %2%3 to their hand. %1 mette %2 in mano%3. - + %1 puts %2%3 into their library. %1 mette %2 nel suo grimorio%3. - + %1 puts %2%3 onto the bottom of their library. %1 mette %2%3 in fondo al proprio grimorio. - + %1 puts %2%3 on top of their library. %1 mette %2 in cima al suo grimorio%3. - + %1 puts %2%3 into their library %4 cards from the top. %1 mette %2%3 nel suo grimorio %4 carte dalla cima. - + %1 moves %2%3 to sideboard. %1 mette %2 nella sideboard%3. - + %1 plays %2%3 face down. %1 gioca %2%3 a faccia in giù. - + %1 plays %2%3. %1 gioca %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 mette %2 nella zona personalizzata '%4'%3 a faccia in giù. - + %1 moves %2%3 to custom zone '%4'. %1 mette %2 nella zona personalizzata '%4'%3. - + %1 tries to draw from an empty library %1 prova a pescare da un grimorio vuoto - + %1 draws %2 card(s). %1 pesca una carta.%1 pesca %2 carte.%1 pesca %2 carte. - + %1 is looking at %2. %1 sta guardando %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 sta guardando %3 carta %4 %2.%1 sta guardando %3 carte %4 %2.%1 sta guardando %3 carte %4 %2. - + bottom in fondo - + top in cima - + %1 turns %2 face-down. %1 gira %2 a faccia in giù. - + %1 turns %2 face-up. %1 gira %2 a faccia in su. - + The game has been closed. La partita è stata chiusa. - + The game has started. La partita è iniziata. - + You are flooding the game. Please wait a couple of seconds. Stai spammando la partita. Attendi un paio di secondi. - + %1 has joined the game. %1 è entrato nella partita. - + %1 is now watching the game. %1 sta osservando la partita. - + You have been kicked out of the game. Sei stato cacciato dalla partita. - + %1 has left the game (%2). %1 ha abbandonato la partita (%2). - + %1 is not watching the game any more (%2). %1 non sta più guardando la partita (%2). - + %1 is not ready to start the game any more. %1 non è più pronto a iniziare la partita. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 mischia il proprio mazzo e pesca una nuova mano di %2 carta.%1 mischia il proprio mazzo e pesca una nuova mano di %2 carte.%1 mischia il proprio mazzo e pesca una nuova mano di %2 carte. - + %1 shuffles their deck and draws a new hand. %1 mischia il proprio mazzo e pesca una nuova mano. - + You are watching a replay of game #%1. Stai guardando il replay della partita #%1. - + %1 is ready to start the game. %1 è pronto a iniziare la partita. - + cards an unknown amount of cards carte - + %1 card(s) a card for singular, %1 cards for plural una carta%1 carte%1 carte - + %1 lends %2 to %3. %1 presta %2 a %3. - + %1 reveals %2 to %3. %1 rivela %2 a %3. - + %1 reveals %2. %1 rivela %2. - + %1 randomly reveals %2%3 to %4. %1 rivela a caso %2%3 a %4. - + %1 randomly reveals %2%3. %1 rivela a caso %2%3. - + %1 peeks at face down card #%2. %1 sbircia la carta a faccia in giù #%2. - + %1 peeks at face down card #%2: %3. %1 sbircia la carta a faccia in giù #%2: %3. - + %1 reveals %2%3 to %4. %1 rivela %2%3 a %4. - + %1 reveals %2%3. %1 rivela %2%3. - + %1 reversed turn order, now it's %2. %1 ha rovesciato l'ordine dei turni, ora è %2. - + reversed invertito - + normal normale - + Heads Testa - + Tails Croce - + %1 flipped a coin. It landed as %2. %1 ha lanciato una moneta. Il risultato è %2. - + %1 rolls a %2 with a %3-sided die. %1 lancia un dado a %3 facce e ottiene %2. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 lancia %2 monete. Sono uscite %3 teste e %4 croci. - + %1 rolls a %2-sided dice %3 times: %4. %1 lancia un dado a %2 facce %3 volte: %4. - + %1's turn. Turno di %1 - + %1 sets annotation of %2 to %3. %1 imposta le note di %2 a %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 imposta il contatore %2 a %3 (%4%5). - + %1 sets %2 to not untap normally. %1 imposta che %2 non STAPpi normalmente. - + %1 sets %2 to untap normally. %1 imposta che %2 STAPpi normalmente. - + %1 removes the PT of %2. %1 elimina i valori F/C di %2. - + %1 changes the PT of %2 from nothing to %4. %1 cambia F/C di %2 da vuota a %4. - + %1 changes the PT of %2 from %3 to %4. %1 cambia F/C di %2 da %3 a %4. - + %1 has locked their sideboard. %1 ha bloccato la sua sideboard. - + %1 has unlocked their sideboard. %1 ha sbloccato la sua sideboard. - + %1 taps their permanents. %1 TAPpa i suoi permanenti. - + %1 untaps their permanents. %1 STAPpa i suoi permanenti. - + %1 taps %2. %1 TAPpa %2. - + %1 untaps %2. %1 STAPpa %2. - + %1 shuffles %2. %1 mescola %2. - + %1 shuffles the bottom %3 cards of %2. %1 mescola le %3 carte sul fondo da %2. - + %1 shuffles the top %3 cards of %2. %1 mescola le prime %3 carte da %2. - + %1 shuffles cards %3 - %4 of %2. %1 mescola le carte %3 - %4 da %2. - + %1 unattaches %2. %1 toglie %2. - + %1 undoes their last draw. %1 annulla la sua ultima pescata. - + %1 undoes their last draw (%2). %1 annulla la sua ultima pescata (%2). @@ -6409,115 +8061,125 @@ Il database delle carte verrà ricaricato. MessagesSettingsPage - + Word1 Word2 Word3 Parola1 Parola2 Parola3 - + Add New Message Aggiungi Nuovo Messaggio - + Edit Message Modifica Messaggio - + Remove Message Rimuovi Messaggio - + Add message Aggiungi messaggio - - + + Message: Messaggio: - + Edit message Modifica messaggio - + Chat settings Impostazioni chat - + Custom alert words Lista parole evidenziate - + Enable chat mentions Abilita menzioni in chat - + Enable mention completer Abilita completamento menzioni - + In-game message macros Messaggi rapidi in partita - + How to use in-game message macros Come usare i messaggi macro in gioco - + Ignore chat room messages sent by unregistered users Ignora i messaggi in chat inviati dagli utenti non registrati - + Ignore private messages sent by unregistered users Ignora i messaggi privati inviati dagli utenti non registrati - + Ignore private messages sent by non-buddy users - - + + Invert text color Inverti colore testo - + Enable desktop notifications for private messages Abilita notifiche desktop per i messaggi privati - + Enable desktop notification for mentions Abilita notifiche sul desktop per le menzioni - + Enable room message history on join Abilita messaggi recenti all'ingresso - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Colore in esadecimale) - + Separate words with a space, alphanumeric characters only Separare le parole con uno spazio; solo caratteri alfanumerici @@ -6525,42 +8187,42 @@ Il database delle carte verrà ricaricato. MoveMenu - + Move to &Metti in - + &Top of library in random order &Cima al grimorio in ordine casuale - + X cards from the top of library... Posizione X dalla cima del grimorio... - + &Bottom of library in random order &Fondo al grimorio in ordine casuale - + T&able Tavolo - + &Hand &Mano - + &Graveyard &Cimitero - + &Exile &Esilio @@ -6639,148 +8301,134 @@ Il database delle carte verrà ricaricato. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - - - Palette Editor — %1 - - - - - <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - - - - This theme ships no default palette files - - - Replace current colours with the theme author's defaults - + Palette Editor — %1 + Modifica Palette — %1 - - Switch between the light and dark palette files + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + <b>Modifica Palette</b> &nbsp;·&nbsp; %1 + + + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6903,30 +8551,21 @@ Il database delle carte verrà ricaricato. Sottofase finale - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - it - - PlayerActions - - - - + + + + grave cimitero - - - - + + + + exile esilio @@ -7045,16 +8684,272 @@ Il database delle carte verrà ricaricato. PlayerMenu - + Player "%1" Giocatore "%1" - + &Counters &Contatori + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7078,12 +8973,12 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic PrintingSelector - + Display Navigation Buttons Visualizza pulsanti di navigazione - + Printing Selector Selettore di stampa @@ -7091,25 +8986,65 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic PrintingSelectorCardOverlayWidget - + Preference Preferenza - + Pin Printing Fissa stampa in cima alla lista - + Unpin Printing Rimuovi stampa dalla cima della lista - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards Mostra carte correlate + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7140,28 +9075,28 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic PrintingSelectorCardSortingWidget - + Alphabetical Alfabetico - + Preference Preferenza - + Release Date Data di rilascio - - + + Descending Discendente - + Ascending Ascendente @@ -7177,95 +9112,103 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic PtMenu - + Power / toughness Forza / costituzione - + &Increase power &Aumenta forza - + &Decrease power &Diminuisci forza - + I&ncrease toughness A&umenta costituzione - + D&ecrease toughness D&iminuisci costituzione - + In&crease power and toughness Au&menta forza e costituzione - + Dec&rease power and toughness Dimi&nuisci forza e costituzione - + Increase power and decrease toughness Aumenta forza e diminuisci costituzione - + Decrease power and increase toughness Diminuisci forza e aumenta costituzione - + Set &power and toughness... Imposta &forza e costituzione... - + Reset p&ower and toughness Resetta f&orza e costituzione + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Servizi - + Hide %1 Nascondi %1 - + Hide Others Nascondi altre - + Show All Mostra tutte - + Preferences... Preferenze... - + Quit %1 Esci da %1 - + About %1 Informazioni su %1 @@ -7273,17 +9216,17 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic QObject - + Cockatrice card database (*.xml) Database delle carte di Cockatrice (*.xml) - + All files (*.*) Tutti i file (*.*) - + Cockatrice replays (*.cor) Replay di Cockatrice (*.cor) @@ -7303,17 +9246,22 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic Token - + + Maybeboard + + + + Overwrite Existing File? Sovrascrivere il file esistente? - + A .cod version of this deck already exists. Overwrite it? Una versione .cod di questo mazzo esiste già. Sovrascriverla? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7330,7 +9278,7 @@ Dovrai usare il Gestore dei set, raggiungibile tramite Database carte -> Orga Sicuro di voler abilitare questa funzione? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7347,100 +9295,110 @@ Potrai anche usare il Gestore dei set per personalizzare l'ordine delle sta Sicuro di voler disabilitare questa funzione? - + Confirm Change Conferma modifica + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK Conferma - + Save Salva - + Save All Salva tutto - + Open Apri - + &Yes &Sì - + Yes to &All Sì a &tutto - + &No &No - + N&o to All N&o a tutto - + Abort Interrompi - + Retry Riprova - + Ignore Ignora - + Close Chiudi - + Cancel Annulla - + Discard Abbandona - + Help Aiuto - + Apply Applica - + Reset Resetta - + Restore Defaults Ripristina predefiniti @@ -7448,121 +9406,189 @@ Sicuro di voler disabilitare questa funzione? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Nome - + ID ID - + Upload time Tempo di upload + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Nome - + Players Giocatori - + Keep Tieni - + Time started Inizio - + Duration (sec) Durata (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7604,37 +9630,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Stanze - + Joi&n E&ntra - + Room Stanza - + Description Descrizione - + Permissions Permessi - + Players Giocatori - + Games Partite @@ -7642,7 +9668,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay Invi&a @@ -7650,32 +9676,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Scegli un'azione dalla tabella - + Hit the key/combination of keys you want to set for this action Premi il tasto/combinazione di tasti che vuoi impostare per questa azione - + Invalid key Tasto non valido - + Shortcut already in use by: Scorciatoia già utilizzata da: - + Clear Pulisci - + Restore default Ripristina predefinito @@ -7708,56 +9734,92 @@ Fine-tune individual colours in the grid afterwards. Data di rilascio + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Ripristina scorciatoie predefinite - + Do you really want to restore all default shortcuts? Sei scuro di voler ripristinare tutte le scorciatoie predefinite? - + Clear all default shortcuts Rimuovi tutte le scorciatoie predefinite - + Do you really want to clear all shortcuts? Sei sicuro di voler rimuovere tutte le scorciatoie? - + Section: Sezione: - + Action: Azione: - + Shortcut: Scorciatoia: - + + Shortcut editor + + + + How to set custom shortcuts Come impostare scorciatoie personalizzate - + Clear all shortcuts Elimina tutte le scorciatoie - + Search by shortcut name Cerca per nome della scorciatoia @@ -7813,12 +9875,12 @@ Controlla le impostazioni! SideboardMenu - + &Sideboard &Sideboard - + &View sideboard &Guarda la sideboard @@ -7826,27 +9888,27 @@ Controlla le impostazioni! SoundSettingsPage - + Enable &sounds Abilita &suoni - + Current sounds theme: Tema sonoro attuale: - + Test system sound engine Prova il funzionamento dei suoni - + Sound settings Impostazioni suoni - + Master volume Volume @@ -7854,48 +9916,48 @@ Controlla le impostazioni! SpoilerBackgroundUpdater - + Spoilers season has ended La stagione degli spoiler è finita - + Deleting spoiler.xml. Please run Oracle Cancellando spoiler.xml. Per favore avviare Oracle - - + + Spoilers download failed Download degli spoiler fallito - + No internet connection Connessione internet assente - + Error Errore - + Spoilers already up to date Gli spoiler sono aggiornati - + No new spoilers added Nessun nuovo spoiler aggiunto - + Spoilers have been updated! Gli spoiler sono stati aggiornati! - + Last change: Ultima modifica: @@ -7903,157 +9965,170 @@ Controlla le impostazioni! StableReleaseChannel - + Default Predefinito - + No reply received from the release update server. Nessuna risposta ricevuta dal server degli aggiornamenti di versione. - + Invalid reply received from the release update server. Risposta non valida ricevuta dal server degli aggiornamenti di versione. - + No reply received from the tag update server. Nessuna risposta ricevuta dal server dei tag di aggiornamento. - + Invalid reply received from the tag update server. Risposta non valida ricevuta dal server dei tag di aggiornamento. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8061,17 +10136,33 @@ Controlla le impostazioni! TabAccount - + + + My Reports + + + + Add to Buddy List Aggiungi a Lista amici - + Add to Ignore List Aggiungi a Lista ignorati - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account Account @@ -8192,118 +10283,118 @@ Controlla le impostazioni! TabArchidekt - - - + + + Desc. Decresc. - - + + AND AND - - + + Require ALL selected colors Richiede TUTTI i colori selezionati - - + + Deck name... Nome mazzo... - - + + Owner... Proprietario... - - + + Packages Package - - + + Advanced Filters Filtri avanzati - + Bracket: Fascia: - - + + Any Qualsiasi - - + + Contains card... Contiene carta... - - + + Commander... Comandante... - - + + Tag... Tag... - - + + Deck Size Dimensione mazzo - + Cards: Carte: - - + + Asc. Cresc. - + Sort by: Ordina per: - + Filter by: Filtra per: - + Display Settings Impostazioni di visualizzazione - - + + Search Cerca - - + + Formats Formati @@ -8316,42 +10407,42 @@ Controlla le impostazioni! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8364,52 +10455,52 @@ Controlla le impostazioni! TabDeckEditor - + Card Info Info carta - + Deck Mazzo - + Filters Filtri - + &View &Visualizza - + Card Database Database carte - + Printing Stampa - + Visible Mostra - + Floating Separata - + Reset layout Reimposta disposizione - + Deck: %1 Mazzo: %1 @@ -8417,49 +10508,49 @@ Controlla le impostazioni! TabDeckEditorVisual - + Visual Deck: %1 Mazzo visuale: %1 - + &Visual Deck Editor Editor &visuale - - + + Card Info Info carta - - + + Deck Mazzo - + &View &Visualizza - + Printing Stampa - + Visible Mostra - + Floating Separata - + Reset layout Reimposta disposizione @@ -8467,22 +10558,22 @@ Controlla le impostazioni! TabDeckEditorVisualTabWidget - + Visual Deck View Mazzo visuale - + Visual Database Display Database visuale - + Deck Analytics Analisi mazzo - + Sample Hand Mano di esempio @@ -8490,138 +10581,236 @@ Controlla le impostazioni! TabDeckStorage - + Local file system File locali - + Server deck storage Mazzi su server - - + + Open in deck editor Apri nell'editor dei mazzi - + Rename deck or folder Rinomina mazzo o cartella - + Upload deck Upload mazzo - + Download deck Scarica mazzo - - - - + + + + New folder Nuova cartella - - + + Delete Elimina - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Apri cartella mazzi - + Rename local folder Rinomina cartella locale - + Rename local file Rinomina file locale - + New name: Nuovo nome: - - - - + + + + + + + Error Errore - + Rename failed Rinomina non riuscita - - + + + Invalid deck file File mazzo non valido - + Enter deck name Inserisci il nome del mazzo - + This decklist does not have a name. Please enter a name: Questo mazzo non ha un nome. Per favore inserisci un nome: - + Unnamed deck Mazzo senza nome - + Failed to upload deck to server Impossibile caricare il mazzo sul server - + Delete local file Elimina file locali - + Are you sure you want to delete the selected files? Vuoi davvero eliminare i file selezionati? - + Delete remote decks Elimina i deck remoti - + Are you sure you want to delete the selected decks? Vuoi davvero eliminare i mazzi selezionati? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Nome della nuova cartella: - + Deck Storage Archivio mazzi @@ -8629,20 +10818,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage Galleria mazzi - + Error Errore - + Could not open deck at %1 Impossibile aprire il mazzo a %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8655,27 +11095,27 @@ Please enter a name: TabEdhRecMain - + &Cards &Carte - + Top Commanders Comandanti più popolari - + Tags Etichette - + Search for a card ... Cerca carta... - + Search Cerca @@ -8688,191 +11128,207 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Partita - - + + Player List Giocatori - - + + + Invite + + + + + Card Info Info carta - - + + Messages Messaggi - - + + Replay Timeline Replay - + &Phases &Fasi - + &Game &Partita - + Next &phase Prossima &fase - + Next phase with &action Prossima sottofase + &azione - + Next &turn Prossimo &turno - + Reverse turn order Inverti l'ordine dei turni - + &Remove all local arrows &Rimuovi tutte le frecce - + Rotate View Cl&ockwise Ruota vista in senso &orario - + Rotate View Co&unterclockwise R&uota vista in senso antiorario - + Game &information &Informazioni partita - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede Rientra in gioco - - - + + + &Concede &Concedi - + &Leave game &Lascia partita - + C&lose replay C&hiudi replay - + &Focus Chat Vai alla &chat - + &Say: &Parla: - + Selected cards Carte selezionate - + &View &Visualizza - + Visible Mostra - + Floating Separata - + Reset layout Reimposta disposizione - + Concede Concedi - + Are you sure you want to concede this game? Vuoi veramente concedere la partita? - + Unconcede Annulla concedi - + You have already conceded. Do you want to return to this game? Hai già concesso. Vuoi ritornare in questa partita? - + Leave game Lascia la partita - + Are you sure you want to leave this game? Sei sicuro di voler lasciare la partita? - + A player has joined game #%1 Un giocatore si è unito alla partita #%1 - + %1 has joined the game %1 si è unito alla partita - + You have been kicked out of the game. Sei stato kickato fuori dalla partita. @@ -8888,158 +11344,158 @@ Please enter a name: TabLog - + Logs Registri - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Ora;NomeMittente;IPMittente;Messaggio;IDDestinatario;NomeDestinatario - + Room Logs Registri stanze - + Game Logs Registri partite - + Chat Logs Registri chat - - + + Error Errore - + You must select at least one filter. Seleziona almeno un filtro. - + You have to select a valid number of days to locate. Seleziona un numero di giorni valido. - + Username: Nome utente: - + IP Address: Indirizzo IP: - + Game Name: Nome partita: - + GameID: ID Partita: - + Message: Messaggio: - + Main Room Stanza principale - + Game Room Stanza partita - + Private Chat Chat privata - + Past X Days: Ultimi X giorni: - + Today Oggi - + Last Hour Ultima ora - + Maximum Results: Massimo numero di risultati: - + At least one filter is required. The more information you put in, the more specific your results will be. E' richiesto perlomeno un filtro. Più informazioni inserisci, più specifici saranno i risultati. - + Get User Logs Ottieni log utente - + Clear Filters Elimina filtri - + Filters Filtri - + Log Locations Posizioni registro - + Date Range Periodo - + Maximum Results Massimo numero di risultati - - + + Message History Storico messaggi - + Failed to collect message history information. Impossibile recuperare la storia dei ban. - + There are no messages for the selected filters. Nessun messaggio corrisponde ai filtri selezionati. @@ -9047,219 +11503,559 @@ Più informazioni inserisci, più specifici saranno i risultati. TabMessage - + Private &chat &Chat privata - + &Leave &Lascia - + %1 - Private chat %1 - Chat privata - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Questo utente ti sta ignorando, non può vedere i tuoi messaggi nella chat principale e tu non puoi entrare nelle sue partite. - + Private message from Messaggio privato da - + %1 has left the server. %1 è uscito dal server. - + %1 has joined the server. %1 è entrato nel server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system File locali - + Server replay storage Replay sul server - - + + Watch replay Guarda replay - + Rename Rinomina - - + + New folder Nuova cartella - - + + Delete Elimina - + Open replays folder Apri cartella replay - + Download replay Scarica replay - + Toggle expiration lock Metti/togli blocco scadenza - + Get replay share code Ottieni codice di condivisione replay - - + + Look up replay by share code Cerca replay dal codice di condivisione - + Rename local folder Rinomina cartella locale - + Rename local file Rinomina file locale - + New name: Nuovo nome: - + Error Errore - + Rename failed Rinomina non riuscita - + Name of new folder: Nome della nuova cartella: - + Delete local file Elimina il file locale - + Are you sure you want to delete the selected files? Vuoi davvero eliminare i file selezionati? - + Are you sure you want to delete the selected replays? Vuoi davvero eliminare i replay selezionati? - + Failed to get code Impossibile ottenere il codice - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. O questo server non supporta la condivisione dei replay, o non consente la condivisione dei replay per te. - - - + + + Failed Fallito - + Could not get replay code Impossibile ottenere il codice replay - + Replay Share Code Codice di condivisione replay - + Others can use this code to add the replay to their list of remote replays: %1 Altre persone possono usare questo codice per aggiungere il replay alla loro lista di replay remoti: %1 - + Copy to clipboard Copia negli appunti - + Replay share code Codice di condivisione replay - + Replay code found Codice replay trovato - + Replay was added, or you already had access to it. Il replay è stato aggiunto oppure ne avevi già accesso. - + Replay code not found Codice replay non trovato - + Failed to submit code Impossibile inviare il codice - + Unexpected error Errore inatteso - + Delete remote replay Elimina replay remoto @@ -9269,65 +12065,478 @@ Più informazioni inserisci, più specifici saranno i risultati. Replay partite + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Parla: - + Chat Chat - + &Room &Stanza - + &Leave room &Lascia stanza - + &Clear chat Pulisci &chat - + Chat Settings... Impostazioni chat... - + mentioned you. ti ha menzionato. - + Click to view Clicca per visualizzare - + You are flooding the chat. Please wait a couple of seconds. Stai spammando la chat. Attendi un paio di secondi. @@ -9335,35 +12544,35 @@ Più informazioni inserisci, più specifici saranno i risultati. TabServer - + Server Server - - - - + + + + Error Errore - + Failed to join the server room: it doesn't exist on the server. Impossibile unirsi alla stanza: non esiste sul server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Il server pensa che tu sia nella stanza ma il tuo client non è in grado di mostrarlo. Prova a riavviare il client. - + You do not have the required permission to join this server room. Non hai i requisiti necessari per unirti a questa stanza. - + Failed to join the server room due to an unknown error: %1. Impossibile unirsi alla stanza a causa di un errore sconosciuto: %1. @@ -9371,97 +12580,173 @@ Più informazioni inserisci, più specifici saranno i risultati. TabSupervisor - + Deck Editor &Editor dei mazzi - + Visual Deck Editor Editor visuale - + EDHRec EDHRec - + Archidekt Archidekt - + Home Home - + &Visual Deck Storage &Galleria mazzi - + Visual Database Display Galleria database - + Server Server - + Account Account - + Deck Storage &Archivio mazzi - + Game Replays &Replay partite - + Administration Amministrazione - + Logs Registri - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Sei sicuro? - + There are still open games. Are you sure you want to quit? Ci sono ancora delle partite aperte. Sei sicuro di voler uscire? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Clicca per visualizzare - + Your buddy %1 has signed on! Il tuo amico %1 si è collegato - + Unknown Event Evento sconosciuto. - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9472,39 +12757,39 @@ Questo messaggio può significare che è disponibile una nuove versione di Cocka Per aggiornare il tuo client, vai su Aiuto -> Controlla aggiornamenti client - + Idle Timeout Timeout inattività - + You are about to be logged out due to inactivity. Stai per essere disconnesso per inattività. - + Promotion Promozione - + You have been promoted. Please log out and back in for changes to take effect. Sei stato promosso. Esci e rientra per dare effetto alle modifiche. - + Warned Avviso - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Hai ricevuto un avviso a causa di %1. Se pregato di evitare di continuare questa attività o potrebbero venire presi ulteriori provvedimenti nel tuoi confronti. Per qualsiasi domanda, manda un messaggio ad un moderatore. - + You have received the following message from the server. (custom messages like these could be untranslated) Hai ricevuto il seguente messaggio dal server. @@ -9524,6 +12809,34 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u Galleria database + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9538,6 +12851,65 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u Impossibile analizzare il mazzo. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9609,57 +12981,52 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9667,216 +13034,266 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u UserContextMenu - + User &details &Dettagli utente - + Private &chat &Chat privata - + Show this user's &games Visualizza le &partite dell'utente - + + View this user's &public decks + + + + Add to &buddy list Aggiungi alla lista &amici - + Remove from &buddy list Rimuovi dalla lista &amici - + Add to &ignore list Aggiungi alla lista &ignorati - + Remove from &ignore list Rimuovi dalla lista &ignorati - + Kick from &game Caccia dalla &partita - + + Report user + + + + Warn user Avvisa utente - + View user's war&n history Mostra storia avvisi ute&nte - + Ban from &server Banna dal &server - + View user's &ban history Mostra storia &ban utente - + &Promote user to moderator &Promuovi utente a moderatore - + Dem&ote user from moderator Degrada il m&oderatore ad utente - + Promote user to &judge Promuovi utente a &giudice - + Demote user from judge Degrada utente da giudice - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes Visualizza le note degli amministratori - - + Investigate user + + + + + + Error Errore - + This user does not exist. L'utente non esiste. - + You are being ignored by %1 and can't see their games. Stai venendo ignorato da %1 e non puoi vedere le sue partite. - + Could not get %1's games. Impossibile ottenere le partite di %1. - + %1's games Partite di %1 - - - + + + Ban History Storico ban - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Ora Ban;Moderatore;Durata Ban;Ragione Ban;Ragione Visibile - + User has never been banned. L'utente non è mai stato bannato. - + Failed to collect ban information. Impossibile recuperare le informazioni sui ban. - - - + + + Warning History Storico avvisi - + Warning Time;Moderator;User Name;Reason Ora Avviso;Moderatore;Nome Utente;Ragione - + User has never been warned. L'utente non ha mai ricevuto avvisi. - + Failed to collect warning information. Impossibile recuperare le informazioni sugli avvisi. - + Failed to get admin notes. Impossibile ottenere le note degli amministratori - - + + Success Successo - + Successfully promoted user. Utente promosso. - + Successfully demoted user. Utente degradato. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Fallito - + Failed to promote user. Promozione fallita. - + Failed to demote user. Degradazione fallita. - + Copy hash to clipboard Copia hash negli appunti - + Remove this user's messages Rimuovi i messaggi di questo utente @@ -9914,61 +13331,61 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u Amministratore - + Moderator Moderatore - + Registered user Utente registrato - - + + Unregistered user Utente non registrato - + Judge Arbitro - + Unknown Sconosciuta - + The entered password does not match your account. La password inserita non corrisponde al tuo accound. - - - - + + + + Information Informazione - + User information updated. Informazioni utente aggiornate. - - - - - - + + + + + - - - + + + + Error Errore @@ -9992,283 +13409,292 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) 1 Anno.%n Anni.%n Anno(i). - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Giorno %20%10%n Giorni %20%10%n Giorno(i) %20 - + Enter Password Inserisci Password - + Password verification is required in order to change your email address La verifica della password è necessaria per cambiare il tuo indirizzo email - - - + + + An error occurred while trying to update your user information. E' stato riscontrato un errore durante l'aggiornamento delle tue informazioni utente. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Questo server non ti permette di aggiornare le tue informazioni utente. - + Password changed. Password modificata. - + This server does not permit you to change your password. Questo server non ti permette di modificare la password. - + The new password is too short. La nuova password è troppo corta. - + The old password is incorrect. La vecchia password è sbagliata. - + Avatar updated. Avatar modificato. - + This server does not permit you to update your avatar. Questo server non ti permette di aggiornare il tuo avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Questo server non ti permette di aggiornare le tue informazioni utente. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10276,223 +13702,344 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u UserInterfaceSettingsPage - + General interface settings Impostazioni generali di interfaccia - + &Double-click cards to play them (instead of single-click) &Doppio click sulle carte per giocarle (anzichè un solo click) - + &Clicking plays all selected cards (instead of just the clicked card) &Cliccare gioca tutte le carte selezionate (anziché solo quella cliccata) - + &Play all nonlands onto the stack (not the battlefield) by default Gioca tutte le carte non terra nella &pila (invece che sul campo di battaglia) - + Do not delete &arrows inside of subphases Non eliminare le &freccie al cambio di sottofase - + Close card view window when last card is removed Chiudi la finestra di visualizzazione carte quando l'ultima carta viene rimossa - + Auto focus search bar when card view window is opened Passa alla barra di ricerca quando si apre la finestra di visualizzazione carta - + Annotate card text on tokens Annota il testo della carta sulle pedine - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Usa menù a strappo, permettendo ai menù del tasto destro del mouse di rimanere sullo schermo - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Impostazioni notifiche - + Enable notifications in taskbar Abilita notifiche nella barra delle applicazioni - + Notify in the taskbar for game events while you are spectating Notifica anche per le partite in cui si è solo uno spettatore - + Notify in the taskbar when users in your buddy list connect Notifca quando utenti nella tua lista amici si connettono - + Animation settings Impostazioni delle animazioni - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation Animazioni &TAPpa/STAPpa - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings Impostazioni editor/archivio mazzi - + Open deck in new tab by default Apri il mazzo in una nuova scheda automaticamente - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby Usa galleria mazzi nella lobby - + Use selection animation for Visual Deck Storage Mostra animazione al passaggio del mouse nella galleria mazzi - + When adding a tag in the visual deck storage to a .txt deck: Quando aggiungi un'etichetta ad un mazzo in formato .txt nella Galleria mazzi: - - do nothing - non fare nulla + + Do nothing + - - ask to convert to .cod - chiedi se convertire in file .cod + + Ask to convert to .cod + - - always convert to .cod - converti sempre in file .cod + + Always convert to .cod + - + Default deck editor type Editor dei mazzi predefinito - + Classic Deck Editor Editor classico - + Visual Deck Editor Editor visuale - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Impostazioni di riproduzione - + Buffer time for backwards skip via shortcut: Tempo di attesa per saltare indietro dopo aver premuto la scorciatoia + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 Utenti connessi al server: %1 - + Users in this room: %1 Utenti in questa stanza: %1 - + Buddies online: %1 / %2 Amici online: %1 / %2 - + Ignored users online: %1 / %2 Utenti ignorati online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters Aumenta tutti i segnalini della carta - + &Untap all permanents &STAPpa tutti i permanenti - + R&oll die... L&ancia un dado... - + Flip coin - + &Create token... &Crea una pedina... - + C&reate another token C&rea un'altra pedina - + Cr&eate predefined token Cr&ea pedina predefinita @@ -10529,22 +14076,22 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... Filtro di ricerca... - + Save Filter Salva filtro - + Save all currently applied filters to a file Salva su file i filtri correnti - + Enter filename... Inserisci il nome del file... @@ -10552,72 +14099,72 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayFilterToolbarWidget - + Sort by Ordina per - + Filter by Filtra per - + Save and load filters Salva e carica filtri - + Filter by exact card name Filtra per nome della carta esatto - + Filter by card main-type Filtra per tipo - + Filter by card sub-type Filtra per sottotipo - + Filter by set Filtra per set - + Filter by format legality Filtra per legalità di formato - + Save/Load Salva/Carica - + Name Nome - + Main Type Tipo - + Sub Type Sottotipo - + Sets Set - + Formats Formati @@ -10719,7 +14266,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filtra per i set più recenti @@ -10727,19 +14274,19 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplaySetFilterWidget - + Search sets... Ricerca set... - - + + Mode: Exact Match Modalità: Corrispondenza esatta - - + + Mode: Includes Modalità: Include @@ -10785,28 +14332,28 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Cerca per nome della carta (o espressioni di ricerca) - - + + Visual Visuale - + Loading database ... Caricamento database... - + Clear all filters Elimina tutti i filtri - + Table Tabella @@ -10814,43 +14361,43 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckDisplayOptionsWidget - + Group by: Raggruppa per: - + Change how cards are divided into categories/groups. Modifica come le carte sono divise in categorie / gruppi. - + Sort by: Ordina per: - + Click and drag to change the sort order within the groups Clicca e trascina per modificare l'ordinamento interno ai gruppi - + Configure how cards are sorted within their groups Configura come le carte sono ordinate all'interno dei loro gruppi - - + + Toggle Layout: Overlap Disposizione: Sovrapposta - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) Modifica come le carte vengono mostrate nelle zone (es. sovrapposte o completamente visibili.) - + Toggle Layout: Flat Disposizione: Piatta @@ -10866,12 +14413,12 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckEditorSampleHandWidget - + Draw a new sample hand Pesca una nuova mano - + Sample hand size Dimensione della mano @@ -10879,17 +14426,17 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckEditorWidget - + Type a card name here for suggestions from the database... Scrivi un nome di carta qui per avere suggerimenti dal database... - + Quick search and add card Cerca e aggiungi carta al mazzo - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Cerca per la corrispondenza più prossima nel database (con auto-completamento) e premendo Invio, aggiungi la stampa preferita della carta al mazzo. @@ -10897,7 +14444,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckStorageFolderDisplayWidget - + Deck Storage Archivio mazzi @@ -10905,52 +14452,57 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckStorageQuickSettingsWidget - + Show Folders Mostra cartelle - + Show Tag Filter Mostra filtro etichette - + Show Color Identity Mostra identità di colore - + Show Tags On Deck Previews Mostra etichette sulle anteprime dei mazzi - + + Show Upload Time + + + + Show Banner Card Selection Option Mostra opzione per selezione copertina - + Draw unused Color Identities Mostra identità di colore inutilizzate - + Unused Color Identities Opacity Opacità identità di colore inutilizzate - + Deck tooltip: Suggerimento mazzo: - + None Nessuno - + Filepath Percorso file @@ -10958,7 +14510,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckStorageSearchWidget - + Search by filename (or search expression) Cerca per nome del file (o espressioni di ricerca) @@ -10966,22 +14518,22 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) Ordina alfabeticamente (Nome del mazzo) - + Sort Alphabetically (Filename) Ordina alfabeticamente (Nome del file) - + Sort by Last Modified Ordina per ultima modifica - + Sort by Last Loaded Ordina per ultimo caricato @@ -10989,17 +14541,22 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckStorageWidget - + Loading database ... Caricamento database... - + Refresh loaded files Aggiorna i file caricati - + + Select decks to share + + + + Visual Deck Storage Settings Impostazioni galleria mazzi @@ -11007,177 +14564,202 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u WarningDialog - + Which warning would you like to send? Che avviso vorresti inviare? - + Redact all messages from this user in all rooms Ottieni tutti i messaggi di questo utente in tutte le stanze - + &OK &OK - + &Cancel &Annulla - + Warn user for misconduct Avvisa l'utente per cattivo comportamento - - + + Error Errore - + User name to send a warning to can not be blank, please specify a user to warn. Il nome utente a cui inviare l'avviso non può essere vuoto. - + Warning to use can not be blank, please select a valid warning to send. L'avviso da inviare non può essere vuoto. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Muovi set selezionato in cima - + Move selected set up Muovi set selezionato su - + Move selected set down Muovi set selezionato giù - + Move selected set to the bottom Muovi set selezionato in fondo - - Search by set name, code, or type - Cerca per nome, codice o tipo del set - - - + Default order Ordine predefinito - + Restore original art priority order Ripristina ordine originale priorità immagini - + Enable all sets Abilita tutti i set - + Disable all sets Disabilita tutti i set - + Enable selected set(s) Abilita i set selezionati - + Disable selected set(s) Disabilita i set selezionati - + Deck Editor Editor dei mazzi - + Use CTRL+A to select all sets in the view. Usa CTRL+A per selezionare le espansioni nella visuale. - + Only cards in enabled sets will appear in the card list of the deck editor. Solo carte in espansioni abilitate appariranno nella lista carte dell'editor di mazzo. - + Image priority is decided in the following order: La priorità di immagine è decisa nell'ordine seguente: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki innanzitutto la cartella CUSTOM (%1), poi le Espansioni abilitate in questo dialogo (da cima a fondo) - + Include cards rebalanced for Alchemy [requires restart] Includi carte ribilanciate per Alchemy [richiede riavvio] - + Card Art Immagine carta - + + Search by set name, code, type, or release date + + + + How to use custom card art Come usare immagini personalizzate per le carte - + Hints Suggerimenti - + Note Note - + Sorting by column allows you to find a set while not changing set priority. L'ordinamento per colonne ti permette di trovare un set senza cambiare il loro ordine di priorità. - + To enable ordering again, click the column header until this message disappears. Per riabilitare l'ordinamento, clicca sull'intestazione della colonna finché questo messaggio non scompare. - + Use the current sorting as the set priority instead Usa l'ordinamento corrente per impostare la priorità dei set - + Sorts the set priority using the same column Imposta priorità dei set usando l'ordinamento per colonna - + Manage sets Organizza set @@ -11185,93 +14767,85 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u ZoneViewWidget - + Search by card name (or search expressions) Cerca per nome della carta (o espressioni di ricerca) - + Ungrouped Non raggruppare - + Group by Type Raggruppa per tipo - + Group by Mana Value Raggruppa per costo - + Group by Color Raggruppa per colore - + Unsorted Non ordinato - + Sort by Name Ordina per nome - + Sort by Type Ordina per tipo - + Sort by Mana Cost Ordina per costo - + Sort by Colors Ordina per colori - + Sort by P/T Ordina per F/C - + Sort by Set Ordina per Set - + shuffle when closing Mescola alla chiusura - + pile view Raggruppa per tipo - - i18n - - - English - Italiano (Italian) - - main - + Connect on startup Connetti all'avvio - + Debug to file Debug su file @@ -12055,6 +15629,16 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u Graveyard Cimitero + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_it.ts b/oracle/translations/oracle_it.ts index e266209d1..1f4f84d78 100644 --- a/oracle/translations/oracle_it.ts +++ b/oracle/translations/oracle_it.ts @@ -2,23 +2,23 @@ IntroPage - + Introduction Introduzione - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Questo wizard importerà la lista di set, carte e pedine che verranno usate da Cockatrice. - + Interface language: Lingua dell'interfaccia: - + Version: Versione: @@ -26,192 +26,206 @@ e pedine che verranno usate da Cockatrice. LoadSetsPage - + Source selection Selezione sorgente - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Specifica una sorgente compatibile per la lista dei set e delle carte. Puoi specificare un indirizzo URL da cui scaricare il file o selezionare un file già presente nel tuo computer. - + Download URL: Indirizzo download: - + Local file: File nel pc: - + Restore default URL Usa l'indirizzo predefinito - + Choose file... Scegli file... - + Load sets file Carica file dei set - + Sets file (%1) Sets JSON file (%1) File dei set (%1) - - - - - - - + + + + + Error Errore - + The provided URL is not valid. L'indirizzo specificato non è valido. - - Downloading (0MB) - Scaricamento (0MB) - - - + Please choose a file. Seleziona un file. - + Cannot open file '%1'. Impossibile aprire il file '%1'. - - Downloading (%1MB) - Scaricamento (%1MB) - - - + Network error: %1. Errore di rete: %1 - + Parsing file Analisi dei file - + Xz extraction failed. Estrazione da file xz fallita. - + Sorry, this version of Oracle does not support xz compressed files. Spiacente, ma questa versione di Oracle non supporta i file xz. - + Failed to open Zip archive: %1. Impossibile aprire il file Zip: %1 - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Estrazione file Zip fallita: lo Zip non contiene un solo file. - + Zip extraction failed: %1. Estrazione file Zip fallita: %1 - + Sorry, this version of Oracle does not support zipped files. Spiacente, ma questa versione di Oracle non supporta i file zip. - + Failed to interpret downloaded data. Impossibile interpretare i dati scaricati. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Vuoi provare a scaricare il file non compresso? - + + The file was retrieved successfully, but it does not contain any sets data. Il file è stato analizzato correttamente, ma non contiene i dati di nessun set. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Salva archivio spoiler - + XML; spoiler database (*.xml) XML; archivio spoiler (*.xml) - + spoiler spoiler - + Spoilers import Importazione spoiler - + Please specify a compatible source for spoiler data. Specifica una sorgente compatibile per gli spoiler. - + Download URL: Indirizzo download: - + Local file: File nel pc: - + Restore default URL Usa l'indirizzo predefinito - + Choose file... Scegli file... - + The spoiler database will be saved at the following location: L'archivio degli spoiler verrà salvato nel seguente percorso: - + Save to a custom path (not recommended) Salva in un percorso diverso (sconsigliato) @@ -219,57 +233,57 @@ e pedine che verranno usate da Cockatrice. LoadTokensPage - + Save token database Salva archivio pedine - + XML; token database (*.xml) XML; archivio pedine (*.xml) - + tokens pedine - + Tokens import Importazione pedine - + Please specify a compatible source for token data. Specifica una sorgente compatibile per le pedine. - + Download URL: Indirizzo download: - + Local file: File nel pc: - + Restore default URL Usa l'indirizzo predefinito - + Choose file... Scegli file... - + The token database will be saved at the following location: L'archivio delle pedine verrà salvato nel seguente percorso: - + Save to a custom path (not recommended) Salva in un percorso diverso (sconsigliato) @@ -277,7 +291,7 @@ e pedine che verranno usate da Cockatrice. OracleImporter - + Dummy set containing tokens Set finto contenente i token @@ -285,7 +299,7 @@ e pedine che verranno usate da Cockatrice. OracleWizard - + Oracle Importer Oracle Importer @@ -293,22 +307,22 @@ e pedine che verranno usate da Cockatrice. OutroPage - + Finished Finito - + The wizard has finished. Il wizard è completato. - + You can now start using Cockatrice with the newly updated cards. Adesso puoi iniziare ad usare Cockatrice con le nuove carte aggiornate. - + If the card databases don't reload automatically, restart the Cockatrice client. Se il database delle carte non si ricarica in automatico, riavvia il programma Cockatrice. @@ -316,73 +330,76 @@ e pedine che verranno usate da Cockatrice. SaveSetsPage - - + + Error Errore - + + No set has been imported. Nessun set importato. - + Sets imported Set importati - - A cockatrice database file of %1 MB has been downloaded. - È stato scaricato un file database di Cockatrice di %1 MB. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: Sono stati trovati i seguenti set: - + Press "Save" to store the imported cards in the Cockatrice database. Premi "Salva" per salvare le carte importate nel database di Cockatrice. - + The card database will be saved at the following location: L'archivio delle carte verrà salvato nel seguente percorso: - + Save to a custom path (not recommended) Salva in un percorso diverso (sconsigliato) - + &Save &Salva - + + Import finished: %1 cards. Importazione conclusa: %1 carte. - + + %1: %2 cards imported %1: %2 carte importate - + Save card database Salva archivio carte - + XML; card database (*.xml) XML; archivio carte (*.xml) - + The file could not be saved to %1 Impossibile salvare il file su %1 @@ -415,8 +432,9 @@ e pedine che verranno usate da Cockatrice. - Downloading (0MB) - Scaricamento (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -588,7 +606,7 @@ e pedine che verranno usate da Cockatrice. i18n - + English Italiano (Italian) @@ -596,12 +614,12 @@ e pedine che verranno usate da Cockatrice. main - + Only run in spoiler mode Avvia solo in modalità spoiler - + Run in no-confirm background mode Esegui in background senza conferma From 44155064b4505bd88e2ca42211eb27b84eb22243 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:49:07 +0200 Subject: [PATCH 056/247] Updates for project Cockatrice and language es (#7551) * Translate oracle_en@source.ts in es [Manual Sync] 93% of minimum 5% translated source file: 'oracle_en@source.ts' on 'es'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in es [Manual Sync] 62% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'es'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_es.ts | 7862 ++++++++++++++++------ oracle/translations/oracle_es.ts | 204 +- 2 files changed, 5834 insertions(+), 2232 deletions(-) diff --git a/cockatrice/translations/cockatrice_es.ts b/cockatrice/translations/cockatrice_es.ts index 2a0e479c3..718e30f82 100644 --- a/cockatrice/translations/cockatrice_es.ts +++ b/cockatrice/translations/cockatrice_es.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Establecer contador... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Establecer contador - + New value for counter '%1': Nuevo valor para el contador '%1': @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab Abrir en nueva pestaña - + Are you sure? ¿Estás seguro? - + The decklist has been modified. Do you want to save the changes? La lista del mazo ha sido modificada. ¿Deseas guardar los cambios? - - - - - - + + + + + + Error Error - + Could not open deck at %1 No se pudo abrir mazo en %1 - + Could not save remote deck No se pudo guardar mazo remoto - - + + The deck could not be saved. Please check that the directory is writable and try again. - + El mazo no pudo ser guardado. Por favor revisa los permisos de escritura del directorio e intenta de nuevo. - + Save deck Guardar mazo - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. El mazo no puede ser guardado. - + There are no cards in your deck to be exported No hay cartas en tu mazo para exportar + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes Notas de actualización - + Admin Notes for %1 @@ -130,224 +179,310 @@ Please check that the directory is writable and try again. Tokens - + Fichas AppearanceSettingsPage - + seconds segundos - + Error Error - + Could not create themes directory at '%1'. No se pudo crear directorio de temas en '%1'. - + Theme settings Preferencias del tema - + Current theme: Tema actual: - + Open themes folder Abrir carpeta de temas - + Home tab background source: Fuente de segundo plano de pestaña de incio: - + Home tab background shuffle frequency: - + La frecuencia de cambiar los fondos en la pestaña de inicio: - + Disabled Deshabilitado - + Menu settings Preferencias del menú - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Representación de las cartas - + Display card names on cards having a picture Mostrar nombre de las cartas en aquellas que tengan imagen - + Auto-Rotate cards with sideways layout - + Girar automáticamente cartas con diseño horizontal - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Sobreescribe todo arte de carta con preferencia de set personal (comportamiento de cambio previo a ProviderID) - + Light - + Modo claro - + Dark - + Modo obscuro - + + System + Sistema + + + + Show all playmats - + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: + Paleta de tema activo + + + + Active theme style: - + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Editar la paleta del tema - + Home tab settings - + Preferencias de pestaña de inicio - + Display card name of background in bottom right + Mostrar el nombre de la carta del reverso en parte inferior derecha + + + + Dim the home tab background - + + Draw a translucent overlay over the home tab background so buttons and text stand out + Poner una superposición translúcida encima de la pestaña de inicio para facilitar la legibilidad del texto y botones. + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Configuraciones de estilo - + Style user list - + Diseño de la lista de usuario - + Card printings - + Impresiones de la carta - + Bump sets that the deck contains cards from to the top in the printing selector - + Empujar colecciones de las que el mazo contenga cartas al tope en el selector de impresion - + Scale cards on mouse over Cambiar tamaño de las cartas al pasar el ratón por encima - + Use rounded card corners Usar esquinas redondeadas de las cartas - + Card layout - + Diseño de carta - + Minimum overlap percentage of cards on the stack and in vertical hand - + Porcentaje minimo de empalme de cartas en la pila y a mano vertical - + Maximum initial height for card view window: - + Altura inicial máxima para vista de ventana de carta - - + + rows filas - + Maximum expanded height for card view window: - + Altura expandida máxima para vista de ventana de carta - + Card counters Contadores de carta - + Counter %1 Contador %1 - + Hand layout Disposición de la mano - + Display hand horizontally (wastes space) Mostrar la mano horizontalmente (desperdicia espacio) - + Enable left justification Habilitar justificación a la izquierda - + Table grid layout Diseño de la cuadrícula de la mesa - + Invert vertical coordinate Invertir coordenada vertical - + Minimum player count for multi-column layout: Número minimo de jugadores para usar la cuadrícula multicolumna: - + Maximum font size for information displayed on cards: Tamaño de fuente máxima para la información presentada en las cartas: @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - Regressar a los resultados + Regresar a los resultados - + Open Deck in Deck Editor Abrir mazo en editor de mazos @@ -375,123 +510,123 @@ Please check that the directory is writable and try again. Art crop of random card - + Recorte de arte a carta al azar Art crop of background.cod deck file - + Recorte de arte al archivo de mazo background.cod BanDialog - + ban &user name banear &usuario - + ban &IP address banear dirección IP &IP - + ban client I&D banear por I&D de cliente - + Ban type Tipo de ban - + &permanent ban acceso prohibido &permanentemente - + &temporary ban acceso prohibido &temporalmente - + &Days: &Días: - + &Hours: &Horas: - + &Minutes: &Minutos: - + Duration of the ban Duración del ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Por favor, introduce el motivo de la restricción de acceso Se almacenará unicamente para moderadores y no podrá ser visto por la persona restringida. - + Please enter the reason for the ban that will be visible to the banned person. Por favor, introduce el motivo de la restricción que será visible para la persona restringida. - + Redact all messages from this user in all rooms Redactar todos los mensajes de este usuario en todas las salas - + &OK Aceptar (&O) - + &Cancel &Cancelar - + Ban user from server Banear usuario del servidor - - - - + + + + Error Error - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Debes seleccionar baneo por nombre, por IP, por id de cliente, o cualquier combinación de los tres. - + You must have a value in the name ban when selecting the name ban checkbox. Debes introducir un nombre cuando la casilla de baneo por nombre está activada. - + You must have a value in the ip ban when selecting the ip ban checkbox. Debes introducir una IP cuando la casilla de baneo por IP está activada. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Debes introducir un id de cliente cuando la casilla de baneo por id de cliente está activada. @@ -499,22 +634,22 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. No se recibió respuesta del servidor de actualización. - + Invalid reply received from the release update server. Respuesta inválida del servidor de actualización. - + No reply received from the file update server. Sin respuesta por parte del servidor de actualización de archivos @@ -522,98 +657,255 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona CardArtPreviewWidget - - No card selected + + Banner preview + + + + + + Zoom %1× + + + + + No card selected + Sin carta seleccionada + CardArtRulesModel - + Card - + Carta - + ProviderId - + ProviderId - + Mode - + Modo - + Reason - + Motivo CardDatabaseModel - + Name Nombre - + Sets Ediciones - + Mana cost Coste de maná - + Card type Tipo de carta - + P/T F/R - + Color(s) Color(es) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Añadir a mazo - + Add to Sideboard - - - - - Select Printing - + Añadir a banquillo + Select Printing + Escoger impresión + + + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Mostrar en EDHRec (Comandante) - + Show on EDHRec (Card) - + Mostrar en EDHRec (Carta) - + Show Related cards - + Mostrar cartas relacionadas @@ -721,22 +1013,22 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona CardInfoFrameWidget - + Image Imagen - + Description Descripción - + Both Ambos - + View transformation Ver transformación @@ -744,22 +1036,22 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona CardInfoPictureWidget - + View related cards Ver cartas relacionadas - + Add card to deck Añadir carta al mazo - + Mainboard Mazo principal - + Sideboard Banquillo @@ -767,27 +1059,27 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona CardInfoTextWidget - + Name: Nombre: - + Set: Expansión - + Collector Number: - + Número de coleccionador - + Related cards: Cartas relacionadas: - + Unknown card: Carta desconocida: @@ -795,129 +1087,129 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona CardMenu - + Re&veal to... - + Revelar a... - + &All players &Todos los jugadores - + View related cards Ver cartas relacionadas - + Token: Ficha: - + All tokens Todas las fichas - + &Select All &Seleccionar todo - + S&elect Row &Seleccionar fila - + S&elect Column &Seleccionar columna - + &Play &Jugar - + &Hide &Ocultar - + Play &Face Down Jugar boca abajo - + &Tap / Untap Turn sideways or back again &Girar / Enderezar - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone &Clonar - + Attac&h to card... Anexar a carta... - + Unattac&h Desanexar - + &Draw arrow... Dibujar flecha... - + &Set annotation... Escribir anotación... - + Reduce life by power - + Ca&rd counters Contadores de carta - + &Add counter (%1) &Añadir contador (%1) - + &Remove counter (%1) &Quitar contador (%1) - + &Set counters (%1)... &Establecer contadores (%1) @@ -927,18 +1219,18 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Network Cache - + Caché de red Filesystem - + Sistema de archivos CardSizeWidget - + Card Size Tamaño de carta @@ -1078,12 +1370,30 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona la zona personalizada de %1 '%2' + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser Parse error at line %1 col %2: - + Error de análisis en linea %1 columna %2: @@ -1091,7 +1401,7 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Parse error at line %1 col %2: - + Error de análisis en linea %1 columna %2: @@ -1099,288 +1409,390 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Click to pick a color - + Clic para elegir un color Pick colour + Elige color + + + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Vetado por un moderador - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + Información - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Reiniciar contraseña - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Conectar a %1... - + Registering to %1 as %2... - + Disconnected - + Desconectado - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + NO - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1538,13 +1950,13 @@ To update your client, go to Help -> Check for Updates. Include Sideboard - + Incluir banquillo DeckEditorCardDatabaseDockWidget - + Card Database Base de datos de cartas @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Buscar por nombre de carta (o buscar expresiones) - + Add card to &maindeck Añadir carta al &mazo principal - + Add card to &sideboard Añadir carta al &banquillo @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... Cargando la base de datos... - + Banner Card - + Main Type Tipo principal de carta - + Mana Cost Coste de maná - + Colors Colores - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Mazo - + Deck &name: &Nombre del mazo: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: &Comentarios: - + Group by: Agrupar por: - + Format: Formato: - + Hash: Hash: - + &Increment number &Incrementar número - + &Decrement number &Reducir número - + &Remove row &Quitar fila - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Filtros - + &Clear all filters &Limpiar todos los filtros - + Delete selected Borrar seleccionado @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor &Editor de mazos - + &New deck &Nuevo mazo - + &Load deck... &Cargar mazo... - + Load recent deck... Cargar mazo reciente... - + Clear Borrar - + &Save deck &Guardar mazo - + Save deck &as... Guardar mazo &como... - + + Share deck... + + + + Load deck from cl&ipboard... Cargar mazo desde el &portapapeles... - + Edit deck in clipboard Editar mazo en el portapapeles - - + + Annotated Anotado - - + + Not Annotated No Anotado - + Save deck to clipboard Guardar mazo al portapapeles - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... Imprimir mazo... - + Load deck from online service... Cargar mazo desde el servicio en línea... - + &Send deck to online service - + Enviar mazo al servicio en línea - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close &Cerrar @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Actualizar Spoilers - + Success Éxito - + Download URLs have been reset. Las URL de descarga se han reiniciado. - + Add URL Añadir URL - - + + URL: URL: - - + + Edit URL Editar URL - + Add New URL Añadir URL - + Remove URL Borrar URL - + Updating... Actualizando... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Elegir camino - + URL Download Priority Prioridad de descarga de URL - + Spoilers Spoilers - + Download Spoilers Automatically Descargar Spoilers automáticamente - + Spoiler Location: Localización del Spoiler: - + Last Change Último cambio - + Spoilers download automatically on launch Spoilers se descargan automáticamente en lanzamiento - + Press the button to manually update without relaunching Presione el botón para actualizar manualmente sin reiniciar - + Do not close settings until manual update is complete No cierre la ventana de configuración hasta que la actualización manual este completa. - + Download card pictures on the fly Descargar imagenes de las cartas rapidamente - + How to add a custom URL Cómo agregar una URL personalizada - + Reset Download URLs Reiniciar descarga de URLs @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo Deshacer - + Redo Rehacer - + Undo/Redo history Historial de deshacer/rehacer - + Click on an entry to revert to that point in the history. - + [redo] [rehacer] - + [undo] [deshacer] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Conteo - + Set Expansión - + Number Número - + Provider ID - + Card Carta @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Modo: Coincidencia Exacta - + Mode: Includes Modo: Incluye - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Editar etiquetas @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Gestor de etiquetas de mazos - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) Añadir una nueva etiqueta (p.e., Aggro) - + Add Tag Añadir etiqueta - + Filter tags... Filtrar etiquetas... - + OK OK - + Edit default tags - + Cancel Cancelar - + Invalid Input - + Tag name cannot be empty! ¡Nombre de etiqueta no puede estar vacío! - + Duplicate Tag Etiqueta Duplicada - + This tag already exists. Esta etiqueta ya existe. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor Abrir en editor de mazos - + + Share deck... + + + + Edit Tags Editar etiquetas - + Rename Deck Renombrar mazo - + Save Deck to Clipboard Guardar mazo al portapapeles - + Annotated Anotado - + Annotated (No set info) - + Not Annotated No Anotado - + Not Annotated (No set info) - + Rename File Renombrar archivo - + Delete File Eliminar archivo - + Set Banner Card - - + + New name: Nuevo nombre: - - + + Rename deck + + + + + Rename file + + + + + Error Error - + Rename failed - + Delete file Eliminar archivo - + Are you sure you want to delete the selected file? ¿Estás seguro de que quieres eliminar el archivo seleccionado? - + Delete failed Borrado fallido @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Renombrar mazo a «%1» desde «%2» - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 Añadido (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Movido a %1 1 × «%2» (%3) - + Removed "%1" (all copies) - - %1 1 × "%2" (%3) + + Moved %1 × "%2" (%3) to %4 - + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + + %1 1 × "%2" (%3) + %1 1 × «%2» (%3) + + + Added Añadido - + Removed Borrado @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Cargar mazo... - + Load remote deck... Cargar mazo remoto... - + Load from clipboard... Cargar desde el portapapeles... - + Load from website... Cargar desde el sitio web... - + Unload deck - + Ready to start Listo para empezar - + Force start Forzar inicio - + Sideboard unlocked Banquillo desbloqueado - + Sideboard locked Banquillo bloqueado - - + + Error Error - + The selected file could not be loaded. El fichero seleccionado no pudo cargarse. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. Descargando... - + Known Hosts Hospedajes Conocidos - + Delete the currently selected saved server - + Refresh the server list with known public servers Actualizar la lista con servidores públicos conocidos - + New Host Nueva dirección - + Name: Nombre: - + &Host: &Dirección: - + &Port: &Puerto: - + Player &name: &Nombre del jugador: - + P&assword: &Contraseña: - + &Save password &Guardar contraseña - + A&uto connect Conectarse a&utomáticamente - + Automatically connect to the most recent login when Cockatrice opens Conectar automáticamente al servidor más reciente cuando se inicie Cockatrice - + If you have any trouble connecting or registering then contact the server staff for help! ¡Si tiene algún problema en conectarse o registrarse puede contactar a nuestro equipo! - - + + Webpage Página web - + Reset Password Reiniciar Contraseña - + Forgot password? ¿Olvidó la contraseña? - + &Connect &Conectar - + Server Servidor - + Login Iniciar sesión - + Server Contact Contacto del Servidor - + Connect to Server Conectar al Servidor - + Server URL dirección URL del servidor - + Communication Port Puerto de comunicación - + Unique Server Name Nombre único para el servidor - + Connection Warning Advertencia de Conexión - + You need to name your new connection profile. Necesita dar nombre a su nuevo perfil de conexión. - + Connect Warning Advertencia de conexión - + The player name can't be empty. El nombre del jugador no puede estar vacío. @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Recordar preferencias (&M) - + &Description: &Descripción: - + P&layers: &Jugadores: - + General General - + Game type Tipo de partida - + &Password: &Contraseña: - + Only &buddies can join Sólo los &amigos pueden participar - + Only &registered users can join Sólo los usuarios &registrados pueden participar - + Joining restrictions Restricciones de participación - + &Spectators can watch Los e&spectadores pueden observar - + Spectators &need a password to watch Los espectadores &necesitan contraseña para unirse - + Spectators can &chat Los espectadores pueden &chatear - + Spectators can see &hands Los espectadores pueden ver las &manos - + Create game as spectator Crear una partida como espectador - + Spectators Espectadores - + Starting life total: Vida inicial total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear Limpiar (&C) - + Create game Crear partida - + Game information Información de la partida - + Error Error - + Server error. Error del servidor. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nombre: - + Token Ficha - + C&olor: &Color: - + white blanco - + blue azul - + black negro - + red rojo - + green verde - + multicolor multicolor - + colorless incoloro - + &P/T: &F/R: - + &Annotation: &Anotación: - + &Destroy token when it leaves the table &Destruir la ficha cuando deje la mesa - + Create face-down (Only hides name) - + Token data Datos de la ficha - + Show &all tokens Mostrar &todas las fichas - + Show tokens from this &deck Mostrar todas las fichas de este &mazo - + Choose token from list Elegir ficha de la lista - + Create token Crear ficha @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags Editar etiquetas - + Add Añadir - + Confirm Confirmar - + Cancel Cancelar - + Enter a tag and press Enter Introduce una etiqueta y pulsa Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! El nombre de la etiqueta no puede estar vacío! - + Duplicate Tag Duplicar etiqueta - + This tag already exists. Esta etiqueta ya existe. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Ninguna imagen seleccionada @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. Para eliminar tu avatar actual, confirma sin elegir una nueva imagen. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Buscar - + Change avatar Cambiar avatar - + Open Image Abrir imagen - + Image Files (*.png *.jpg *.bmp) Archivos de imagen (*.png *.jpg *.bmp) - + Invalid image chosen. La imagen seleccionada no es válida. @@ -2836,38 +3454,38 @@ Para eliminar tu avatar actual, confirma sin elegir una nueva imagen. DlgEditPassword - + Old password: Contraseña anterior: - + New password: Nueva contraseña: - + Confirm new password: Confirmar la nueva contraseña: - + Change password Cambiar contraseña - - + + Error Error - + Your password is too short. Su contraseña es muy corta - + The new passwords don't match. Las nuevas contraseñas no coinciden. @@ -2875,93 +3493,93 @@ Para eliminar tu avatar actual, confirma sin elegir una nueva imagen. DlgEditTokens - + &Name: &Nombre: - + C&olor: &Color: - + white blanco - + blue azul - + black negro - + red rojo - + green verde - + multicolor multicolor - + colorless incoloro - + &P/T: &F/R: - + &Annotation: &Anotación: - + Token data Información de la ficha - - + + Add token Añadir ficha - + Remove token Eliminar ficha - + Edit custom tokens Editar fichas personalizadas - + Please enter the name of the token: Por favor, introduzca el nombre de la ficha: - + Error Error - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. El nombre elegido ya existe y está siendo usado por una carta o ficha. @@ -3040,7 +3658,8 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante - Hide games that have started + Hide started games + Hide games that have started @@ -3076,8 +3695,9 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante - &Creator name: - &Nombre del creador: + &Host name: + &Creator name: + @@ -3143,52 +3763,52 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante DlgForgotPasswordChallenge - + Reset Password Challenge Warning Reiniciar contraseña de desafíos con advertencia - + A problem has occurred. Please try to request a new password again. Ha ocurrido un problema. Por favor intenta solicitar una nueva contraseña. - + Enter the information of the server and the account you'd like to request a new password for. Introduce la información del servidor y de la cuenta para la que deseas solicitar una nueva contraseña. - + &Host: &Dirección: - + &Port: &Puerto: - + Player &name: &Nombre del jugador: - + Email: Correo electrónico: - + Reset Password Challenge Reiniciar contraseña de desafío - + Reset Password Challenge Error Error al reiniciar contraseña de desafío - + The email address can't be empty. La dirección de correo electrónico no puede estar vacía. @@ -3196,37 +3816,37 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Introduce la información del servidor para la que deseas solicitar una nueva contraseña. - + &Host: &Dirección: - + &Port: &Puerto: - + Player &name: &Nombre del jugador: - + Reset Password Request Error al reiniciar la petición para una contraseña - + Reset Password Error Error al reiniciar la contraseña - + The player name can't be empty. El nombre del jugador no puede estar vacío. @@ -3234,94 +3854,127 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante DlgForgotPasswordReset - + Reset Password Warning Reinicia la contraseña de advertencia - + A problem has occurred. Please try to request a new password again. Ha ocurrido un problema. Por favor intenta solicitar de nuevo una nueva contraseña. - + Enter the received token and the new password in order to set your new password. Introduce el token recibido y la nueva contraseña para establecer tu nueva contraseña. - + &Host: &Dirección: - + &Port: &Puerto: - + Player &name: &Nombre del jugador: - + Token: Ficha: - - + + New Password: Nueva contraseña: - + Reset Password Reiniciar contraseña - + The player name can't be empty. El nombre del jugador no puede estar vacío. - - - - + + + + Reset Password Error Error al reiniciar contraseña - + The token can't be empty. La ficha no puede estar vacía. - + The new password can't be empty. La nueva contraseña no puede estar vacía. - + Error Error - + Your password is too short. Tu contraseña es demasiado corta. - + The passwords do not match. Las contraseñas no coinciden. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck Cargar mazo @@ -3370,7 +4023,7 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante Network error: %1 - + Error de la red: %1 @@ -3405,173 +4058,574 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: Jugadores: - + General General - + Starting life total: Total de vidas inicial: - + Game setup options - + Remember settings Recordar preferencias - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Nombre de carta (o buscar expresiones): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + Poner cartas superiores en la pila hasta... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice Cockatrice - + Invalid filter Filtro no válido + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Introduce tu información y la información del servidor en el que deseas registrarte. Tu correo electrónico se utilizará para verificar tu cuenta. - + &Host: &Dirección: - + &Port: &Puerto: - + Player &name: &Nombre del jugador: - + P&assword: &Contraseña: - + Password (again): Contraseña (de nuevo): - + Email: Correo electrónico: - + Email (again): Correo electrónico (de nuevo): - + Country: País: - + Undefined Indefinido - + Real name: Nombre real: - + Register to server Registrarse en el servidor - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Advertencia durante el registro - + Your password is too short. Tu contraseña es demasiado corta. - + Your passwords do not match, please try again. Tus contraseñas no coinciden, por favor inténtalo de nuevo. - + Your email addresses do not match, please try again. Tus direcciones de correo electrónico no coinciden, por favor inténtalo de nuevo. - + The player name can't be empty. El nombre del jugador no puede estar vacío. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice Number of sides: - + Cantidad de lados: @@ -3630,46 +4684,12 @@ Tu correo electrónico se utilizará para verificar tu cuenta. DlgSettings - + Unknown Error loading card database Error desconocido al cargar la base de datos de cartas. - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Tu base de datos de cartas no es válida. - -Cockatrice podría no funcionar correctamente con una base de datos inválida. - -Podrías necesitar volver a ejecutar oracle para actualizar tu base de datos de cartas. - -¿Quieres cambiar la ubicación de tu base de datos? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Tu base de datos de cartas es demasiado antigua. - -Esto puede causar problemas al cargar la información o las imágenes de las cartas. - -Normalmente esto se soluciona volviendo a ejecutar oracle para actualizar tu base de datos. - -¿Quieres cambiar la ubicación de tu base de datos? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3682,7 +4702,7 @@ Por favor, envía un ticket en https://github.com/Cockatrice/Cockatrice/issues c ¿Te gustaría cambiar la configuración de la ubicación de tu base de datos? - + File Error loading your card database. Would you like to change your database location setting? @@ -3691,7 +4711,7 @@ Would you like to change your database location setting? ¿Quieres cambiar la ubicación de tu base de datos? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3700,7 +4720,7 @@ Would you like to change your database location setting? ¿Quieres cambiar la ubicación de tu base de datos? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3713,104 +4733,235 @@ Por favor, envía un ticket en https://github.com/Cockatrice/Cockatrice/issues ¿Te gustaría cambiar la configuración de la ubicación de tu base de datos? - - - + + + Error Error - + The path to your deck directory is invalid. Would you like to go back and set the correct path? La ruta a tu directorio de mazos no es válida. ¿Deseas volver y seleccionar la ruta correcta? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? La ruta a tu directorio de imágenes de las cartas no es válida. ¿Deseas volver y seleccionar la ruta correcta? - + Settings Preferencias - + + Search settings... + + + + + OK + + + + General General - + Appearance Apariencia - + User Interface Interfaz de usuario - + Storage - + Almacén - + Card Sources Origen de la carta - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Sonido - + Shortcuts Atajos de teclado + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground Ejecutar en primer plano - + Run in background Ejecutar en segundo plano - + Run in background and always from now on Ejecutar en segundo plano y siempre a partir de ahora - + Don't prompt again and don't run - + Don't run this time No ejecutar esta vez @@ -3818,17 +4969,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Siguiente - + Previous Anterior - + Tip of the Day Tip del día @@ -3836,165 +4987,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel Canal de lanzamiento actual - + Reinstall Reinstalar - + Cancel Download Cancelar Descarga - + Open Download Page Abrir página de descarga - + Check for Client Updates Comprobar actualizaciones del cliente - - - - + + + + Error Error - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice no fue creado con soporte para SSL, ¡por lo que no se pueden descargar actualizaciones automáticamente! Por favor, visita la página de descargas para actualizarlo manualmente. - + Downloading update: %1 Descargando actualización: %1 - + Checking for updates... Buscando actualizaciones... - + Finished checking for updates Comprobación de actualizaciones finalizada - + No Update Available Actualización no disponible - + Cockatrice is up to date! ¡Cockatrice se ha actualizado! - + You are already running the latest version available in the chosen release channel. Actualmente está ejecutando la última versión disponible en el canal de distribución. - + Current version Versión actual - + Selected release channel Seleccionado canal de lanzamiento - - + + Update Available Actualización disponible - - + + A new version of Cockatrice is available! ¡Una nueva version de Cockatrice está disponible! - - + + New version Nueva versión - - + + Released Lanzado - - + + Changelog Registro de cambios - + Do you want to update now? ¿Quieres actualizar ahora? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Error en la actualización - + An error occurred while checking for updates: Ha ocurrido un error mientras se buscaban actualizaciones: - + An error occurred while downloading an update: Ha ocurrido un error mientras se descargaba una actualización: - + Installing... Instalando... - + Cockatrice is unable to open the installer. Cockatrice es incapaz de abrir el instalador. - + Try to update manually by closing Cockatrice and running the installer. Intente actualizar manualmente cerrando Cockatrice y ejecutando el instalador. - + Download location Ubicación de descarga @@ -4002,17 +5163,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing Limpiar el historial al cerrar - + Copy to clipboard Copiar al portapapeles - + Debug Log Registro de depuración @@ -4047,7 +5208,7 @@ You may have to manually download the new version. Mana Value - + Valor de maná @@ -4062,12 +5223,12 @@ You may have to manually download the new version. Exactly - + Exactamente Quantity (N): - + Cantidad (N) @@ -4111,7 +5272,7 @@ You may have to manually download the new version. Mana Value - + Valor de maná @@ -4121,7 +5282,7 @@ You may have to manually download the new version. Exactly - + Exactamente @@ -4136,12 +5297,12 @@ You may have to manually download the new version. Category - + Categoría Qty - + Cantidad @@ -4154,7 +5315,7 @@ You may have to manually download the new version. In %1 decks - + En %1 mazos @@ -4192,7 +5353,7 @@ You may have to manually download the new version. MTG Stocks - + MTG Stocks @@ -4250,15 +5411,15 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: - + Sal: FilterBuilder - + Type your filter here Escriba su filtro aquí @@ -4266,45 +5427,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? ¿Estás seguro/a de que quieres eliminar el filtro '%1'? - + Delete Failed Borrado fallido - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator expulsado por el anfitrión del juego o moderador - + player left the game jugador ha dejado la partida - + player disconnected from server jugador se ha desconectado del servidor - + reason unknown razón desconocida @@ -4312,140 +5516,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Error - + Please join the appropriate room first. Por favor, entre a la sala adecuada primero. - + Wrong password. Contraseña incorrecta. - + Spectators are not allowed in this game. No se permiten espectadores en esta partida. - + The game is already full. La partida no tiene plazas libres. - + The game does not exist any more. La partida ya no existe. - + This game is only open to registered users. Esta partida está abierta sólo a usuarios registrados. - + This game is only open to its creator's buddies. Esta partida está abierta sólo a los amigos del creador. - + You are being ignored by the creator of this game. Estás siendo ignorado por el creador de la partida. - + Join Game Unirse a partida - + Spectate Game Espectar partida - + Game Information Información de la partida - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game Entrar en la partida - - Password: - Contraseña: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Por favor, entre primero en la sala respectiva. - + &Filter games &Filtrar partidas - + C&lear filter &Limpiar filtro - + C&reate C&rear - + &Join Unirse (&J) - + Join as judge - + J&oin as spectator Entrar como e&spectador - + Join as judge spectator - + Games shown: %1 / %2 Juegos mostrados: %1 / %2 - + Games Partidas @@ -4475,7 +5695,7 @@ You may have to manually download the new version. Hide full games - + Esconder los partidos llenos @@ -4486,105 +5706,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 day - + %1%2 hr short age in hours %1%2 hora%1%2 horas%1%2 horas - + new nuevo - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password contraseña - + buddies only solo amigos - + reg. users only solo usuarios registrados - + open decklists - - + + can chat pueden usar el chat - + see hands ver manos - + can see hands puede ver manos - + not allowed no permitidos - + Room Sala - + Age Edad - + Description Descripción - - Creator - Creador + + Host + - + Type Tipo - + Restrictions Restricciones - + Players Jugadores - + Spectators Espectadores @@ -4592,158 +5812,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Reiniciar todos los caminos - + All paths have been reset Todos los caminos se han reiniciado - - - - - - - + + + + English + Español (Spanish) + + + + + + + + + Choose path Elija ruta - - Language settings + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> - + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + + Language settings + Preferencias de la idioma + + + Language: Idioma: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Preferencias de versión - + Card database - + Base de datos de cartas - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Rutas (edición deshabilitada en modo portatil) - + Paths Rutas - + How to help with translations Cómo ayudar con las traducciones - + Decks directory: Directorio de mazos: - + Filters directory: Directorio de filtros: - + Replays directory: Directorio de repeticiones: - + Pictures directory: Directorio de imágenes: - + Card database: Base de datos de cartas: - + Custom database directory: Directorio de base de datos personalizado: - + Token database: Base de datos de tokens: - + Update channel Actualizar canal - + Check for client updates on startup Comprobar actualizaciones del cliente al arrancar - + Check for card database updates on startup Comprobar actualizaciones de la base de datos de cartas al arrancar - + Don't check No comprobar - + Prompt for update - + Always update in the background Siempre actualizar en segundo plano - + Check for card database updates every Comprobar actualizaciones de la base de datos de cartas cada - + days días - + Notify if a feature supported by the server is missing in my client Notificar si hace falta en mi cliente alguna característica soportada por el servidor - + Automatically run Oracle when running a new version of Cockatrice Oracle se ejecutará automáticamente cuando se ejecuté una nueva versión de Cockatrice - + Show tips on startup Mostrar tips al inicio - + Last update check on %1 (%2 days ago) @@ -4751,47 +6088,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard &Cementerio - + &View graveyard Ver &cementerio - + &Move graveyard to... &Mover cementerio a... - + &Top of library &Parte superior de la biblioteca - + &Bottom of library &Parte inferior de la biblioteca - + &All players Todos los jugadores - + &Hand &Mano - + &Exile &Exilio - + Reveal random card to... Mostrar una carta al azar a... @@ -4811,7 +6148,7 @@ You may have to manually download the new version. Sort hand by... - + Ordenar mano por... @@ -4826,7 +6163,7 @@ You may have to manually download the new version. Mana Value - + Valor de maná @@ -4885,59 +6222,353 @@ You may have to manually download the new version. Mostrar una carta al azar a... + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck Crear nuevo mazo - + Browse Decks Buscar mazos - + Browse Card Database Buscar base de datos de cartas - + Browse EDHRec Buscar EDHRec - + Browse Archidekt Buscar Archidekt - + View Replays Ver repeticiones - + Quit Quitar - + Connecting... Conectando... - + Connect Conectar - + Play Jugar + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5008,7 +6639,7 @@ You may have to manually download the new version. D&raw cards... - + Robar cartas... @@ -5033,7 +6664,7 @@ You may have to manually download the new version. Put top card on &bottom - + Poner carta superior en la parte inferior @@ -5068,7 +6699,7 @@ You may have to manually download the new version. Put top cards on stack &until... - + Poner cartas superiores en la pila hasta... @@ -5144,7 +6775,7 @@ You may have to manually download the new version. Reveal top cards of library - + Revelar cartas superiores de la biblioteca @@ -5155,273 +6786,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Jugador %1 - + Load replay Cargar repetición - + About Cockatrice Acerca de Cockatrice - + Version Versión - + Cockatrice Webpage Página de Cockatrice - + Project Manager: Jefe de Proyecto: - + Past Project Managers: Antiguos Jefes de Proyecto: - + Developers: Desarrolladores: - + Our Developers Nuestros desarrolladores - + Help Develop! ¡Ayuda a desarrollar! - + Translators: Traductores: - + Our Translators Nuestros Traductores - + Help Translate! ¡Ayuda a traducir! - + Support: Soporte: - + Report an Issue Informa de un problema - + Troubleshooting Problemas frecuentes - + F.A.Q. Preguntas frecuentes - - + + Error Error - + &Connect... &Conectar... - + &Disconnect &Desconectar - + Start &local game... Empezar partida &local... - + &Watch replay... &Ver repetición... - + &Full screen &Pantalla completa - + &Register to server... &Registrarse en el servidor... - + &Restore password... &Restaurar contraseña... - + &Settings... &Preferencias... - + &Exit &Salir - + A&ctions A&cciones - + &Cockatrice &Cockatrice - + C&ard Database Base de datos de c&artas - + &Manage sets... &Mantenedor de ediciones... - + Edit custom &tokens... Editar &fichas personalizadas... - + Open custom image folder Abrir carpeta de imágenes personalizadas - + Open custom sets folder Abrir carpeta de ediciones personalizadas - + Add custom sets/cards Agregar cartas/ediciones personalizadas - + Reload card database Recargar la base de datos de cartas - + Tabs Pestañas - + &Help A&yuda - + &About Cockatrice &Acerca de Cockatrice - + &Tip of the Day &Tip del Día - + Check for Client Updates Buscar Actualizaciones de Cliente - + Check for Card Updates... Comprobar actualizaciones de cartas... - + Check for Card Updates (Automatic) Comprobar actualizaciones de cartas (Automático) - + Show Status Bar Mostrar barra de estatus - + View &Debug Log Ver &registro de debugs - + Open Settings Folder Abrir carpeta de preferencias - + + Re-run Onboarding Wizard... + + + + Show/Hide Mostrar/Ocultar - + + Sign-in cancelled + + + + New Version Nueva Versión - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Felicitaciones se ha actualizado Cockatrice %1! Oracle actualizará tu base de datos de cartas. - + Cockatrice installed Cockatrice está instalado. - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. ¡Felicitaciones! Se ha instalado Cockatrice %1 Ahora se ejecutará Oracle para instalar la base de datos de cartas inicial. - + + Are you sure? + ¿Estás seguro? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Base de datos de cartas - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5430,29 +7082,29 @@ If unsure or first time user, choose "Yes" Si no está seguro o es su primera vez, seleccione "Sí" - - + + Yes Si - - + + No No - + Open settings Abrir preferencias - + New sets found Nuevas ediciones encontradas - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5465,22 +7117,22 @@ Códigos de sets: %1 ¿Quieres habilitarlos? - + Yes, always enable - + View sets Ver ediciones - + Welcome Bienvenido/a - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5489,89 +7141,89 @@ Todas las ediciones en la base de datos de cartas han sido habilitadas. Aprenda más sobre cambiar el orden de la edición o deshabilitando ediciones especificas y otras preferencias en la opción "Mantenedor de ediciones". - + Information Información - + A card database update is already running. La actualización de la base de datos de cartas ya está en marcha. - + Unable to run the card database updater: Imposible iniciar el actualizador de la base de datos de cartas: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. Ocurrió un error desconocido. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Cargar ediciones/cartas - + Selected file cannot be found. El archivo seleccionado no fue encontrado. - + You can only import XML databases at this time. Solo puedes importar bases de datos en XML en este momento. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Las nuevas ediciones/cartas se han agregado correctamente. Cockatrice está recargando la base de datos de cartas. - + Sets/cards failed to import. Las ediciones/cartas fallaron al ser importadas. @@ -5591,12 +7243,12 @@ Cockatrice está recargando la base de datos de cartas. pie - + gráfico circular bar - + gráfico de barras @@ -5606,12 +7258,12 @@ Cockatrice está recargando la base de datos de cartas. Filter Colors (optional): - + Filtrar colores (opcional): OK - + OK @@ -5632,7 +7284,7 @@ Cockatrice está recargando la base de datos de cartas. Group By: - + Agrupar por: @@ -5662,7 +7314,7 @@ Cockatrice está recargando la base de datos de cartas. Filters (optional): - + Filtros (opcional): @@ -5693,12 +7345,12 @@ Cockatrice está recargando la base de datos de cartas. pie - + gráfico circular bar - + gráfico de barras @@ -5708,7 +7360,7 @@ Cockatrice está recargando la base de datos de cartas. Filter Colors (optional): - + Filtrar colores (opcional): @@ -5729,17 +7381,17 @@ Cockatrice está recargando la base de datos de cartas. pie - + gráfico circular bar - + gráfico de barras Colors: - + Colores @@ -5776,616 +7428,616 @@ Cockatrice está recargando la base de datos de cartas. MessageLogWidget - + from play del juego - + from their graveyard de su cementerio - + from exile del exilio - + from their hand de su mano - + the top card of %1's library la carta superior de la biblioteca de %1 - + the top card of their library la parte superior de su biblioteca - + from the top of %1's library desde la parte superior de la biblioteca de %1 - + from the top of their library Desde la parte superior de su biblioteca - + the bottom card of %1's library la carta de la parte inferior de la biblioteca de %1 - + the bottom card of their library La carta de la parte inferior de su biblioteca - + from the bottom of %1's library desde la parte inferior de la biblioteca de %1 - + from the bottom of their library Desde la parte inferior de su biblioteca - + from %1's library desde la biblioteca de %1 - + from their library desde su biblioteca - + from sideboard desde el banquillo - + from the stack desde la pila - + from custom zone '%1' desde la zona personalizada '%1' - + %1 is now keeping the top card %2 revealed. %1 está manteniendo la carta superior %2 revelada. - + %1 is not revealing the top card %2 any longer. %1 ya no mantiene revelada la carta superior %2. - + %1 can now look at top card %2 at any time. %1 ahora puede ver la carta superior de %2 en cualquier momento. - + %1 no longer can look at top card %2 at any time. %1 ya no puede ver la carta superior de %2 en cualquier momento. - + %1 attaches %2 to %3's %4. %1 anexa %2 a %3's %4. - + %1 has conceded the game. %1 ha concedido la partida. - + %1 has unconceded the game. %1 ha concedido la partida - + %1 has restored connection to the game. %1 ha recuperado la conexión a la partida. - + %1 has lost connection to the game. %1 ha perdido la conexión a la partida. - + %1 points from their %2 to themselves. %1 apunta desde su %2 a si mismo. - + %1 points from their %2 to %3. %1 apunta desde su %2 a %3. - + %1 points from %2's %3 to themselves. %1 apunta desde el %3 de %2 a si mismo. - + %1 points from %2's %3 to %4. %1 apunta desde el %3 de %2 a %4. - + %1 points from their %2 to their %3. %1 apunta de su %2 a su %3. - + %1 points from their %2 to %3's %4. %1 apunta desde su %2 al %4 de %3. - + %1 points from %2's %3 to their own %4. %1 apunta desde el %3 de %2 a su %4. - + %1 points from %2's %3 to %4's %5. %1 apunta desde el %3 de %2 al %5 de %4. - + %1 creates a face down token. - + %1 crea una ficha boca abajo. - + %1 creates token: %2%3. %1 crea una ficha: %2%3. - + %1 has loaded a deck (%2). %1 ha cargado un mazo (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 ha cargado un mazo con %2 cartas en el banquillo (%3). - + %1 destroys %2. %1 destruye %2. - + a card una carta - + %1 gives %2 control over %3. %1 entrega a %2 el control sobre %3. - + %1 puts %2 into play%3 face down. %1 pone %2 en juego %3 bocabajo. - + %1 puts %2 into play%3. %1 pone %2 en juego %3. - + %1 puts %2%3 into their graveyard face down. %1 pone %2%3 en su cementerio boca abajo. - + %1 puts %2%3 into their graveyard. %1 pone %2%3 en su cementerio. - + %1 exiles %2%3 face down. %1 exilia %2%3 boca abajo. - + %1 exiles %2%3. %1 exilia %2%3. - + %1 moves %2%3 to their hand. %1 mueve %2%3 a su mano. - + %1 puts %2%3 into their library. %1 pone %2%3 en su biblioteca. - + %1 puts %2%3 onto the bottom of their library. %1 pone %2%3 al fondo de su biblioteca. - + %1 puts %2%3 on top of their library. %1 pone %2%3 en la parte superior de su biblioteca. - + %1 puts %2%3 into their library %4 cards from the top. %1 pone %2%3 en la biblioteca de su propietario en el %4 lugar desde la parte superior - + %1 moves %2%3 to sideboard. %1 mueve %2%3 al banquillo. - + %1 plays %2%3 face down. %1 juga %2%3 boca abajo. - + %1 plays %2%3. %1 juega %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 mueve %2%3 a la zona personalizada '%4' boca abajo. - + %1 moves %2%3 to custom zone '%4'. %1 mueve %2%3 a la zona personalizada '%4' - + %1 tries to draw from an empty library - + %1 trata de robar desde una biblioteca vacía. - + %1 draws %2 card(s). - %1 roba %2 carta(s).%1 roba %2 c%1 roba %2 c + %1 roba %2 carta.%1 roba %2 cartas.%1 roba %2 carta(s). - + %1 is looking at %2. %1 está mirando a %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom parte inferior - + top parte superior - + %1 turns %2 face-down. %1 pone %2 boca abajo. - + %1 turns %2 face-up. %1 pone %2 boca arriba. - + The game has been closed. La partida ha sido cerrada. - + The game has started. La partida ha comenzado. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 se ha unido a la partida. - + %1 is now watching the game. %1 está ahora observando la partida. - + You have been kicked out of the game. Has sido expulsado de la partida. - + %1 has left the game (%2). %1 ha dejado la partida (%2). - + %1 is not watching the game any more (%2). %1 ya no está viendo el juego (%2). - + %1 is not ready to start the game any more. %1 está preparado/a para empezar la partida. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 barajea su mazo y roba una nueva mano de %2 carta(s).%1 barajea su mazo y roba una nueva mano de %2 carta(s)%1 barajea su mazo y roba una nueva mano de %2 carta(s) - + %1 shuffles their deck and draws a new hand. %1 Baraja su maso y robas una nueva mano. - + You are watching a replay of game #%1. Estás viendo una repetición de la partida #%1. - + %1 is ready to start the game. %1 está preparado/a para empezar la partida. - + cards an unknown amount of cards cartas - + %1 card(s) a card for singular, %1 cards for plural - %1 carta(s)%1 cars%1 cars + %1 carta%1 cartas%1 carta(s) - + %1 lends %2 to %3. %1 presta %2 a %3. - + %1 reveals %2 to %3. %1 revela %2 a %3. - + %1 reveals %2. %1 revela %2. - + %1 randomly reveals %2%3 to %4. %1 revela al azar %2%3 a %4. - + %1 randomly reveals %2%3. %1 revela al azar %2%3. - + %1 peeks at face down card #%2. %1 está mirando la carta #%2 boca abajo. - + %1 peeks at face down card #%2: %3. %1 mira la carta boca abajo #%2: %3. - + %1 reveals %2%3 to %4. %1 revela %2%3 a %4. - + %1 reveals %2%3. %1 revela %2%3. - + %1 reversed turn order, now it's %2. %1 Orden del turno invertido, ahora es %2. - + reversed Invertido. - + normal Normal. - + Heads Cara - + Tails Cruz - + %1 flipped a coin. It landed as %2. %1 tira una moneda. Ha salido %2. - + %1 rolls a %2 with a %3-sided die. %1 sacó un %2 con un dado de %3 caras. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 lanza %2 monedas. Hay %3 que salió cara y %4 que salió cruz - + %1 rolls a %2-sided dice %3 times: %4. - + %1 lanza un dado de %2 caras %3 veces: %4. - + %1's turn. Turno %1. - + %1 sets annotation of %2 to %3. %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 establece el contador %2 a %3 (%4%5). - + %1 sets %2 to not untap normally. %1 establece que %2 no se endereze normalmente. - + %1 sets %2 to untap normally. %1 establece que %2 se endereze normalmente. - + %1 removes the PT of %2. %1 elimina la F/R de %2 - + %1 changes the PT of %2 from nothing to %4. %1 cambia la F/R de %2 de nada a %4. - + %1 changes the PT of %2 from %3 to %4. %1 cambia la F/R de %2 de %3 a %4. - + %1 has locked their sideboard. %1 ha bloqueado su banquillo. - + %1 has unlocked their sideboard. %1 ha desbloqueado su banquillo. - + %1 taps their permanents. %1 gira sus permanentes. - + %1 untaps their permanents. %1 ha enderezado sus permanentes. - + %1 taps %2. %1 gira %2. - + %1 untaps %2. %1 endereza %2. - + %1 shuffles %2. %1 baraja %2. - + %1 shuffles the bottom %3 cards of %2. %1 barajea las últimas %3 cartas de %2. - + %1 shuffles the top %3 cards of %2. %1 barajea las primeras %3 cartas de %2. - + %1 shuffles cards %3 - %4 of %2. %1 barajea las cartas %3 - %4 de %2. - + %1 unattaches %2. %1 desanexa %2. - + %1 undoes their last draw. %1 deshace su último robo. - + %1 undoes their last draw (%2). %1 deshace su último robo (%2). @@ -6393,115 +8045,125 @@ Cockatrice está recargando la base de datos de cartas. MessagesSettingsPage - + Word1 Word2 Word3 Palabra1 Palabra2 Palabra3 - + Add New Message Añadir nuevo mensaje - + Edit Message Editar mensaje - + Remove Message Borrar mensaje - + Add message Añadir mensaje - - + + Message: Mensaje: - + Edit message Editar mensaje - + Chat settings Preferencias de Chat - + Custom alert words Palabras de alerta personalizadas - + Enable chat mentions Habilitar las menciones en el chat - + Enable mention completer Habilitar completado de menciones - + In-game message macros Macros para mensajes durante la partida - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignorar mensajes de sala de usuarios sin registrar - + Ignore private messages sent by unregistered users Ignorar mensajes privados de usuarios sin registrar - + Ignore private messages sent by non-buddy users - - + + Invert text color Invertir el color del texto - + Enable desktop notifications for private messages Habilitar notificaciones de escritorio para los mensajes privados - + Enable desktop notification for mentions Habilitar notificaciones de escritorio para menciones - + Enable room message history on join Habilitar historial de mensajes de sala al unirse - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (El color es hexadecimal) - + Separate words with a space, alphanumeric characters only Separa las palabras con un espacio, solo caracteres alfanuméricos @@ -6509,42 +8171,42 @@ Cockatrice está recargando la base de datos de cartas. MoveMenu - + Move to Mover a - + &Top of library in random order - + X cards from the top of library... - + X cartas desde la parte superior de la biblioteca... - + &Bottom of library in random order - + T&able Tabla - + &Hand &Mano - + &Graveyard &Cementerio - + &Exile &Exilio @@ -6623,148 +8285,134 @@ Cockatrice está recargando la base de datos de cartas. PaletteEditorDialog - - + + Reset - + Reiniciar - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Deshabilitado - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6887,30 +8535,21 @@ Cockatrice está recargando la base de datos de cartas. Paso final - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - es - - PlayerActions - - - - + + + + grave cementerio - - - - + + + + exile exilio @@ -6920,7 +8559,7 @@ Cockatrice está recargando la base de datos de cartas. View top cards of library - + Ver cartas superiores de la biblioteca @@ -6943,17 +8582,17 @@ Cockatrice está recargando la base de datos de cartas. Shuffle top cards of library - + Barajar cartas superiores de la biblioteca Shuffle bottom cards of library - + Barajar cartas inferiores de la biblioteca Draw hand - + Robar mano @@ -6963,32 +8602,32 @@ Cockatrice está recargando la base de datos de cartas. Draw cards - + Robar cartas Move top cards to %1 - + Movar las cartas superiores a %1 Move bottom cards to %1 - + Movar las cartas inferiores a %1 Draw bottom cards - + Robar cartas inferiores Create tokens - + Crear fichas Number: - + Numero: @@ -7029,16 +8668,272 @@ Cockatrice está recargando la base de datos de cartas. PlayerMenu - + Player "%1" - Jugador(a) "%1" + Jugador(a) «%1» - + &Counters Contadores + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7059,12 +8954,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7072,23 +8967,63 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference Preferencia - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + Mostrar cartas relacionadas + + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. @@ -7105,7 +9040,7 @@ This setting means you'll only see the default printing for each card, instead o Previous Card in Deck - + Carta anterior en el mazo @@ -7121,28 +9056,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Alfabético - + Preference Preferencia - + Release Date Fecha de lanzamiento - - + + Descending Descendente - + Ascending Ascendente @@ -7158,95 +9093,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness Fuerza / resistencia - + &Increase power &Incrementar fuerza - + &Decrease power &Reducir fuerza - + I&ncrease toughness I&ncrementar resistencia - + D&ecrease toughness R&educir resistencia - + In&crease power and toughness In&crementar fuerza y resistencia - + Dec&rease power and toughness Red&ucir fuerza y resistencia - + Increase power and decrease toughness Incrementa fuerza y reducir resistencia - + Decrease power and increase toughness Reducir fuerza e incrementar resistencia - + Set &power and toughness... Establecer &fuerza y resistencia... - + Reset p&ower and toughness Reiniciar f&uerza y resistencia + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Servicios - + Hide %1 Ocultar %1 - + Hide Others Ocultar otros - + Show All Mostrar todo - + Preferences... Preferencias... - + Quit %1 Salir %1 - + About %1 Sobre %1 @@ -7254,17 +9197,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Base de datos de cartas de Cockatrice (*.xml) - + All files (*.*) Todos los archivos (*.*) - + Cockatrice replays (*.cor) Repeticiones de Cockatrice (*.cor) @@ -7284,17 +9227,22 @@ This setting means you'll only see the default printing for each card, instead o Fichas - + + Maybeboard + + + + Overwrite Existing File? ¿Sobrescribir archivo existente? - + A .cod version of this deck already exists. Overwrite it? Ya existe una versión .cod de este mazo. ¿Sobrescribirla? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7305,7 +9253,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7316,100 +9264,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Guardar - + Save All Guardar todo - + Open Abrir - + &Yes &Sí - + Yes to &All Sí a &Todo - + &No &No - + N&o to All N&o a todo - + Abort Abortar - + Retry Volver a intentar - + Ignore Ignorar - + Close Cerrar - + Cancel Cancelar - + Discard Descartar - + Help Ayuda - + Apply Aplicar - + Reset Reiniciar - + Restore Defaults Restaurar predeterminados @@ -7417,121 +9375,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Nombre - + ID ID - + Upload time Tiempo de subida + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Nombre - + Players Jugadores - + Keep Mantener - + Time started Tiempo de inicio - + Duration (sec) Duración (seg.) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7573,37 +9599,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Salas - + Joi&n E&ntrar - + Room Sala - + Description Descripción - + Permissions Permisos - + Players Jugadores - + Games Partidas @@ -7611,7 +9637,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay Decir @@ -7619,32 +9645,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Selecciona una acción de la tabla - + Hit the key/combination of keys you want to set for this action Presiona la tecla/combinación de teclas que desees configurar para esta acción - + Invalid key Clave inválida - + Shortcut already in use by: - + Clear Limpiar - + Restore default Restaurar configuración predeterminada @@ -7677,58 +9703,94 @@ Fine-tune individual colours in the grid afterwards. Fecha de lanzamiento + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Restaurar atajos por defecto - + Do you really want to restore all default shortcuts? ¿Realmente quieres restablecer los atajos predeterminados? - + Clear all default shortcuts Limpiar todos los atajos predeterminados - + Do you really want to clear all shortcuts? ¿Realmente quieres limpiar los atajos predeterminados? - + Section: Sección: - + Action: Acción: - + Shortcut: Atajo: - + + Shortcut editor + + + + How to set custom shortcuts Cómo fijar atajos personalizados - + Clear all shortcuts - + Limpiar todos los atajos - + Search by shortcut name - + Buscar por nombre de atajo @@ -7781,12 +9843,12 @@ Por favor, revise su configuración de atajos. SideboardMenu - + &Sideboard &Banquillo - + &View sideboard Ver banquillo @@ -7794,27 +9856,27 @@ Por favor, revise su configuración de atajos. SoundSettingsPage - + Enable &sounds Activar &sonidos - + Current sounds theme: Tema de sonidos actual: - + Test system sound engine Probar el audio del sistema - + Sound settings Preferencias de sonido - + Master volume Volumen maestro @@ -7822,48 +9884,48 @@ Por favor, revise su configuración de atajos. SpoilerBackgroundUpdater - + Spoilers season has ended La temporada de Spoilers ha terminado - + Deleting spoiler.xml. Please run Oracle Eliminando spoiler.xml Por favor, ejecute Oracle - - + + Spoilers download failed Error al descargar Spoilers - + No internet connection No hay conexión a internet - + Error Error - + Spoilers already up to date Los spoilers ya están actualizados - + No new spoilers added No se han añadido nuevos spoilers - + Spoilers have been updated! Los Spoilers han sido actualizados! - + Last change: Último cambio: @@ -7871,175 +9933,204 @@ Por favor, revise su configuración de atajos. StableReleaseChannel - + Default Por defecto - + No reply received from the release update server. No se recibe respuesta del servidor de lanzamiento de actualizaciones. - + Invalid reply received from the release update server. Respuesta invalida del servidor de lanzamiento de actualizaciones. - + No reply received from the tag update server. No se recibe respuesta del servidor de etiquetas de actualizaciones. - + Invalid reply received from the tag update server. Respuesta invalida del servidor de etiquetas de actualizaciones. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Las imágenes de las cartas en caché se han reiniciado. - + Downloaded card pictures have been reset. - + Las imágenes de las cartas descargadas se han reiniciado. - + Error - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Eliminar las imágenes en caché - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + Sistema de archivos - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) - + Día(s) TabAccount - - Add to Buddy List + + + My Reports - + + Add to Buddy List + Añadir a la lista de amigos + + + Add to Ignore List Añadir a la Lista de Ignorados - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account Cuenta @@ -8160,224 +10251,224 @@ Por favor, revise su configuración de atajos. TabArchidekt - - - + + + Desc. - - + + AND Y - - + + Require ALL selected colors - - + + Deck name... Nombre del mazo... - - + + Owner... - + Propietario/a... - - + + Packages - + Paquetes - - + + Advanced Filters - + Bracket: - - + + Any Alguno - - + + Contains card... - - + + Commander... Comandante... - - + + Tag... - + Etiqueta - - + + Deck Size Tamaño de mazo - + Cards: Cartas: - - + + Asc. - + Sort by: - + Ordenar por: - + Filter by: Filtrar por: - + Display Settings - - + + Search - + Buscar - - + + Formats Formatos Archidekt: - + Archidekt TabCardArtRules - + Card: - + Carta: - + ProviderId: - - - Mode: - - - Reason: - + Mode: + Modo: - + + Reason: + Razón: + + + Type a card name... - + Add rule - + Añadir regla - + Remove rule - + Borrar regla - + Refresh - + Actualizar Card Art Rules - + Reglas de arte de carta TabDeckEditor - + Card Info Información de la carta - + Deck Mazo - + Filters Filtros - + &View &Ver - + Card Database Base de datos de cartas - + Printing - + Visible Visible - + Floating Flotante - + Reset layout Reinicializar la disposición - + Deck: %1 Mazo: %1 @@ -8385,72 +10476,72 @@ Por favor, revise su configuración de atajos. TabDeckEditorVisual - + Visual Deck: %1 Mazo visual: %1 - + &Visual Deck Editor Editor de mazos visual - - + + Card Info Información de la carta - - + + Deck Mazo - + &View Ver - + Printing - + Visible Visible - + Floating - + Reset layout - + Restablecer disposición TabDeckEditorVisualTabWidget - + Visual Deck View Vista de mazos visual - + Visual Database Display - + Deck Analytics Análisis de mazo - + Sample Hand @@ -8458,139 +10549,237 @@ Por favor, revise su configuración de atajos. TabDeckStorage - + Local file system Sistema de archivos local - + Server deck storage Almacen de mazos del servidor - - + + Open in deck editor Abrir en el editor de mazos - + Rename deck or folder Renombrar mazo o carpeta - + Upload deck Subir mazo - + Download deck Descargar mazo - - - - + + + + New folder Nueva carpeta - - + + Delete Borrar - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Abrir carpeta de mazos - + Rename local folder Renombrar carpeta local - + Rename local file - + Renombrar archivo local - + New name: Nombre nuevo: - - - - + + + + + + + Error Error - + Rename failed - - + + + Invalid deck file Archivo de baraja inválido - + Enter deck name Introduzca el nombre del mazo - + This decklist does not have a name. Please enter a name: Esta lista de mazo no tiene un nombre. Por favor, introduce un nombre: - + Unnamed deck Mazo sin nombre - + Failed to upload deck to server Fallo al subir baraja al servidor - + Delete local file Borrar fichero local - + Are you sure you want to delete the selected files? ¿Estás seguro/a de que quieres eliminar los archivos seleccionados? - + Delete remote decks Borrar mazos remotos - + Are you sure you want to delete the selected decks? ¿Estás seguro/a de que quieres eliminar los mazos seleccionados? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Nombre de la nueva carpeta: - + Deck Storage Almacén de mazos @@ -8598,20 +10787,271 @@ Por favor, introduce un nombre: TabDeckStorageVisual - + Visual Deck Storage Almacén de mazos visual - + Error Error - + Could not open deck at %1 No se pudo abrir mazo en %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8624,27 +11064,27 @@ Por favor, introduce un nombre: TabEdhRecMain - + &Cards &Cartas - + Top Commanders - + Los mejores comandantes - + Tags Etiquetas - + Search for a card ... Buscar una carta ... - + Search Buscar @@ -8657,191 +11097,207 @@ Por favor, introduce un nombre: TabGame - - - + + + Replay Repetir - - + + Game Partida - - + + Player List Lista de jugadores - - + + + Invite + + + + + Card Info Información de la carta - - + + Messages Mensajes - - + + Replay Timeline Historial de partidas - + &Phases &Fases - + &Game &Partida - + Next &phase Próxima &fase - + Next phase with &action Próxima fase con &acción - + Next &turn Próximo &turno - + Reverse turn order Orden del turno invertido. - + &Remove all local arrows &Retirar todas las flechas locales - + Rotate View Cl&ockwise Girar en sentido horario - + Rotate View Co&unterclockwise Girar en sentido antih&orario (&U) - + Game &information &Información de la partida - - Un&concede + + Cop&y game link - - - + + Invite to Game... + + + + + Un&concede + Revertir rendición + + + + + &Concede &Conceder - + &Leave game &Abandonar la partida - + C&lose replay &Cerrar repetición - + &Focus Chat &Resaltar chat - + &Say: &Decir: - + Selected cards Cartas seleccionadas - + &View &Ver - + Visible Visible - + Floating Flotante - + Reset layout Resetear la distribución - + Concede Conceder - + Are you sure you want to concede this game? ¿Estás seguro de que quieres conceder esta partida? - + Unconcede Revertir rendición - + You have already conceded. Do you want to return to this game? Ya te rendiste. ¿Quieres regresar a este juego? - + Leave game Abandonar la partida - + Are you sure you want to leave this game? ¿Estás seguro de que quieres abandonar la partida? - + A player has joined game #%1 Un jugador se ha unido a la partida #%1 - + %1 has joined the game %1 se ha unido a la partida - + You have been kicked out of the game. Has sido expulsado de la partida. @@ -8857,158 +11313,158 @@ Por favor, introduce un nombre: TabLog - + Logs Historial - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs Registros de sala - + Game Logs Registros de partidas - + Chat Logs Registros de chat - - + + Error Error - + You must select at least one filter. Debes seleccionar como mínimo un filtro. - + You have to select a valid number of days to locate. Debes seleccionar un número de días válido para buscar. - + Username: Nombre de usuario: - + IP Address: Dirección IP: - + Game Name: Nombre de partida: - + GameID: ID de partida: - + Message: Mensaje: - + Main Room Sala Principal - + Game Room Sala de Partida - + Private Chat Chat privado - + Past X Days: Pasados X días: - + Today Hoy - + Last Hour Última hora - + Maximum Results: Resultados máximos: - + At least one filter is required. The more information you put in, the more specific your results will be. Se requiere un filtro como mínimo. Cuando más información introduzcas, más específicos serán los resultados. - + Get User Logs Ver registros de usuario - + Clear Filters Borrar filtros - + Filters Filtros - + Log Locations Localización de registro. - + Date Range Alcance de fecha - + Maximum Results Resultados máximos - - + + Message History Historial de mensajes - + Failed to collect message history information. Error al recopilar información del historial de mensajes. - + There are no messages for the selected filters. No hay mensajes con los filtros seleccionados @@ -9016,218 +11472,558 @@ Cuando más información introduzcas, más específicos serán los resultados. TabMessage - + Private &chat &Chat privado - + &Leave &Cerrar - + %1 - Private chat %1 - Chat privado - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. El usuario te esta ignorando, los usuarios no podrán ver tus mensajes en el chat principal y no podrás unirte a sus partidas. - + Private message from Mensaje privado de - + %1 has left the server. %1 ha abandonado el servidor. - + %1 has joined the server. %1 se ha unido al servidor. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Sistema de archivos local - + Server replay storage Almacén de repeticiones del servidor - - + + Watch replay Ver repetición - + Rename Renombrar - - + + New folder Nueva carpeta - - + + Delete Borrar - + Open replays folder Abrir carpeta de repeticiones - + Download replay Descargar repetición - + Toggle expiration lock Alternar expiración del bloqueo - + Get replay share code - - + + Look up replay by share code - + Rename local folder Renombrar carpeta local - + Rename local file - - - - - New name: - + Renombrar archivo local + New name: + Nuevo nombre + + + Error Error - + Rename failed - + Name of new folder: Nombre de nueva carpeta - + Delete local file Borrar fichero local - + Are you sure you want to delete the selected files? ¿Estás seguro/a de que quieres eliminar los archivos seleccionados? - + Are you sure you want to delete the selected replays? ¿Estás seguro/a de que quieres eliminar los repeticiones seleccionados? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed Fallido - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard Copiar al portapapeles - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Borrar repetición remota @@ -9237,65 +12033,478 @@ Cuando más información introduzcas, más específicos serán los resultados.Repetición de partidas + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Decir: - + Chat Chat - + &Room &Sala - + &Leave room &Dejar sala - + &Clear chat &Limpiar chat - + Chat Settings... Preferencias del Chat... - + mentioned you. te ha mencionado. - + Click to view Click para ver - + You are flooding the chat. Please wait a couple of seconds. Estás saturando el chat. Por favor, espera unos segundos. @@ -9303,35 +12512,35 @@ Cuando más información introduzcas, más específicos serán los resultados. TabServer - + Server Servidor - - - - + + + + Error Error - + Failed to join the server room: it doesn't exist on the server. Fallo al unirse a la sala del servidor: no existe en el servidor. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. El servidor cree que estás en la sala del servidor pero tu cliente es incapaz de mostrarlo. Intenta reiniciar tu cliente. - + You do not have the required permission to join this server room. No tienes los permisos requeridos para unirte a esta sala del servidor. - + Failed to join the server room due to an unknown error: %1. Fallo al unirse a la sala del servidor debido a un error desconocido: %1. @@ -9339,97 +12548,173 @@ Cuando más información introduzcas, más específicos serán los resultados. TabSupervisor - + Deck Editor Editor de mazos - + Visual Deck Editor Editor de mazos visual - + EDHRec EDHRec - + Archidekt - + Archidekt - + Home Inicio - + &Visual Deck Storage Almacén de mazos visual - + Visual Database Display - + Server Servidor - + Account Cuenta - + Deck Storage Almacén de mazos - + Game Replays Repetición de partidas - + Administration Administración - + Logs + Historial + + + + Report Queue - + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? ¿Estás seguro? - + There are still open games. Are you sure you want to quit? Todavía hay partidas abiertas. ¿Estás seguro/a de que quieres salir? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Click para ver - + Your buddy %1 has signed on! Tu amigo %1 se ha conectado! - + Unknown Event Evento desconocido - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9439,39 +12724,39 @@ Este mensaje podría significar que hay una nueva versión de Cockatrice disponi Para actualizar tu cliente, ve a Ayuda -> Buscar Actualizaciones. - + Idle Timeout Tiempo de ausencia agotado - + You are about to be logged out due to inactivity. Va a ser desconectado por inactividad. - + Promotion Promoción - + You have been promoted. Please log out and back in for changes to take effect. Has sido promocionado. Por favor cierra la sesión y vuelve a iniciarla para que los cambios surtan efecto. - + Warned Advertido - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Has recibido una advertencia debido a %1 Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas contra usted. Si tiene alguna pregunta, por favor envíe un mensaje privado a un moderador. - + You have received the following message from the server. (custom messages like these could be untranslated) Ha recibido los siguientes mensajes del servidor. @@ -9491,6 +12776,34 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9505,6 +12818,65 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Análisis de mazo no disponible. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9574,57 +12946,52 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas UserCardArtSettingsDialog - + Card Art Settings - + Preferencias de arte de carta - + Type a card name... - + Card name: + Nombre de carta: + + + + Printing: - - Card ProviderId: + + Card - + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9632,216 +12999,266 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas UserContextMenu - + User &details &Detalles del usuario - + Private &chat &Chat privado - + Show this user's &games Mostrar &partidas de este usuario - + + View this user's &public decks + + + + Add to &buddy list Añadir a la lista de &amigos - + Remove from &buddy list Quitar de la lista de &amigos - + Add to &ignore list Añadir a la lista de &ignorados - + Remove from &ignore list Quitar de la lista de &ignorados - + Kick from &game Expulsar de la &partida - + + Report user + + + + Warn user Usuario advertido - + View user's war&n history Ver historial de advertencias del usuario - + Ban from &server Banear del &servidor - + View user's &ban history Ver historial historial de baneo del usuario. - + &Promote user to moderator &Promocionar usuario a moderador - + Dem&ote user from moderator Degradar usuario de m&oderador - + Promote user to &judge Usuario promovido a &juez - + Demote user from judge Degradar usuario de juez - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error Error - + This user does not exist. Este usuario no existe. - + You are being ignored by %1 and can't see their games. Estás siendo ignorado por %1 y no puedes ver sus partidos. - + Could not get %1's games. - + %1's games Partidas de %1 - - - + + + Ban History Historial de baneo - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. El usuario nunca ha sido baneado - + Failed to collect ban information. Error al recopilar informacion de baneos. - - - + + + Warning History Historial de advertencias - + Warning Time;Moderator;User Name;Reason Warning Time;Moderator;User Name;Reason - + User has never been warned. El usuario nunca ha sido advertido - + Failed to collect warning information. Error al recopilar información de advertencias. - + Failed to get admin notes. - - + + Success Éxito - + Successfully promoted user. Usuario promocionado con éxito. - + Successfully demoted user. Usuario degradado con éxito. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Fallo - + Failed to promote user. Fallo al promocionar usuario. - + Failed to demote user. Fallo al degradar usuario. - + Copy hash to clipboard Copiar al portapapeles - + Remove this user's messages Borrar mensajes de este usuario @@ -9879,61 +13296,61 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Administrador - + Moderator Moderador - + Registered user Usuario registrado - - + + Unregistered user Usuario no registrado - + Judge Juez - + Unknown Desconocido - + The entered password does not match your account. La contraseña introducida no coincide con tu cuenta - - - - + + + + Information Información - + User information updated. Información del usuario actualizada. - - - - - - + + + + + - - - + + + + Error Error @@ -9957,283 +13374,292 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n año,%n años,%n año(s), - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n día %20%10%n días %20%10%n días %20 - + Enter Password Introducir contraseña - + Password verification is required in order to change your email address Se requiere verificación de la contraseña para cambiar tu dirección de correo electrónico - - - + + + An error occurred while trying to update your user information. Un error ha ocurrido mientras intentaba actualizarse tu información de usuario. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Este servidor no permite actualizar su información de usuario. - + Password changed. Tu contraseña ha sido cambiada. - + This server does not permit you to change your password. Este servidor no permite cambiar tu contraseña. - + The new password is too short. La nueva contraseña es muy corta - + The old password is incorrect. La contraseña anterior no es correcta. - + Avatar updated. Avatar actualizado - + This server does not permit you to update your avatar. Este servidor no permite actualizar avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Este servidor no permite actualizar su información de usuario. - UserInfoPopup - - + + Games - + Partidos - + Chat - + Chat - + Open private chat - + Abrir chat privado - + Profile - + Perfil - + View user profile - + Ver perfil de usuario - + Show this user's games + Mostrar los partidos de este usuario + + + + Invite - + + Invite to your game + + + + − Buddy - + − Amigo - + Remove from buddy list - + Quitar de la lista de amigos - + + Buddy - + + Amigo - + Add to buddy list - + Añadir a la lista de amigos - + − Ignore - + Remove from ignore list - + Quitar de la lista de ignorados - + + Ignore - + Add to ignore list - + Añadir a la lista de ignorados - + Ban - + Ban from server - + Warn - + Advertir - + Warn user - + Ban log - + View ban history - + Ver historial de baneo - + Warn log - + View warning history - + Ver historial de advertencias - + Notes - + Notas - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10241,223 +13667,344 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas UserInterfaceSettingsPage - + General interface settings Preferencias generales de la interfaz - + &Double-click cards to play them (instead of single-click) &Doble click en las cartas para jugarlas (en lugar de un solo click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Jugar todas las cartas que no sean tierras en la pila (en lugar de en el campo de batalla) por defecto. - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Anotar en las fichas. - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Usar menus flotantes, permitir click derecho para anclar menus en la pantalla - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Ajustes de notificaciones - + Enable notifications in taskbar Habilitar notificaciones en la barra de tareas - + Notify in the taskbar for game events while you are spectating Notificar en la barra de tareas los eventos de la partida mientras estás como espectador - + Notify in the taskbar when users in your buddy list connect Recibir una notificación en la barra de tareas cuando alguien de tu lista de amigos se conecte - + Animation settings Preferencias de animación - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation Animación de &girar/enderezar - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings Preferencias de editor/almacén de mazos - + Open deck in new tab by default + Abrir mazo en nueva pestaña por defecto + + + + Show card counts in Visual Deck Editor - + Use visual deck storage in game lobby Usar almacén de mazos visual en el vestíbulo de juego - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing - no hacer nada - - - - ask to convert to .cod + + Do nothing - - always convert to .cod + + Ask to convert to .cod - + + Always convert to .cod + + + + Default deck editor type - + Tipo de editor de mazos por defecto - + Classic Deck Editor - + Editor de mazos clásico - + Visual Deck Editor Editor de mazos visual - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Preferencias de repetición - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Usuarios conectado al servidor: %1 - + Users in this room: %1 - + Usuarios en esta sala: %1 - + Buddies online: %1 / %2 Amigos en línea: %1 / %2 - + Ignored users online: %1 / %2 Usuarios ignorados en línea: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + Enderezar todos los permanentes - + R&oll die... &Lanzar dado... - + Flip coin - + Lanzar una moneda - + &Create token... Crear ficha... - + C&reate another token Crear otra ficha - + Cr&eate predefined token @@ -10477,13 +14024,13 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Includes - + Incluye Include / Exclude Mode: Includes - + Incluir / Excluir @@ -10494,22 +14041,22 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter Guardar filtro - + Save all currently applied filters to a file - + Enter filename... @@ -10517,72 +14064,72 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Ordenar por: - + Filter by Filtrar por - + Save and load filters Guardar y cargar filtros - + Filter by exact card name Filtrar por nombre de carta exacto - + Filter by card main-type Filtrar por tipo principal de carta - + Filter by card sub-type Filtrar por subtipo de carta - + Filter by set Filtrar por expansión - + Filter by format legality - + Save/Load Guardar/Cargar - + Name Nombre - + Main Type Tipo principal - + Sub Type Subtipo - + Sets Ediciones - + Formats Formatos @@ -10625,7 +14172,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Show main types with at least: - + Mostrar tipos principales con al menos: @@ -10684,7 +14231,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10692,19 +14239,19 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplaySetFilterWidget - + Search sets... Buscar ediciones... - - + + Mode: Exact Match Modo: Coincidencia Exacta - - + + Mode: Includes Modo: Incluye @@ -10719,7 +14266,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Show sub types with at least: - + Mostrar subtipos de carta con al menos: @@ -10750,28 +14297,28 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Buscar por nombre de carta (o buscar expresiones) - - + + Visual Visual - + Loading database ... Cargando la base de datos... - + Clear all filters Limpiar todos los filtros - + Table Tabla @@ -10779,43 +14326,43 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckDisplayOptionsWidget - + Group by: - + Agrupar por: - + Change how cards are divided into categories/groups. - + Sort by: - + Ordenar por: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10831,12 +14378,12 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckEditorSampleHandWidget - + Draw a new sample hand Robar una nueva mano de muestra - + Sample hand size @@ -10844,17 +14391,17 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10862,7 +14409,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckStorageFolderDisplayWidget - + Deck Storage Almacén de mazos @@ -10870,52 +14417,57 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckStorageQuickSettingsWidget - + Show Folders Mostrar las carpetas - + Show Tag Filter - + Show Color Identity - + Mostrar identidad de color - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None Ninguno - + Filepath @@ -10923,7 +14475,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10931,22 +14483,22 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10954,17 +14506,22 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckStorageWidget - + Loading database ... Cargando la base de datos... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings Preferencias de almacén de mazos visual @@ -10972,177 +14529,202 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas WarningDialog - + Which warning would you like to send? ¿Qué advertencia quieres enviar? - + Redact all messages from this user in all rooms Redactar todos los mensajes de este usuario en todas las salas - + &OK &OK - + &Cancel &Cancelar - + Warn user for misconduct Usuario advertido por conducta inadecuada - - + + Error Error - + User name to send a warning to can not be blank, please specify a user to warn. El nombre de usuario no puede quedarse en blanco, por favor especifique un usuario al que advertir. - + Warning to use can not be blank, please select a valid warning to send. La advertencia no puede quedarse en blanco, por favor seleccione una advertencia válida. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Mover el set seleccionado al principio - + Move selected set up Mover arriba el set seleccionado - + Move selected set down Mover abajo el set seleccionado - + Move selected set to the bottom Mover el set seleccionado al final - - Search by set name, code, or type - Buscar por nombre de edición, código, o tipo - - - + Default order Orden por defecto - + Restore original art priority order Restaurar orden predeterminado de arte original - + Enable all sets Seleccionar todos los sets - + Disable all sets Deseleccionar todos los sets - + Enable selected set(s) Habilitar set(s) seleccionados - + Disable selected set(s) Deshabilitar set(s) seleccionados - + Deck Editor Editor de mazo - + Use CTRL+A to select all sets in the view. Usa CTRL+A para seleccionar todas las colecciones aquí mostradas. - + Only cards in enabled sets will appear in the card list of the deck editor. Solo las cartas en las colecciones permitidas aparecerán en la lista de cartas del editor de mazos. - + Image priority is decided in the following order: La prioridad de las imágenes es decidida en el siguiente orden: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki primero la Carpeta PERSONALIZADA (%1), luego las Ediciones Habilitadas en este mensaje (de Arriba a Abajo) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Arte de carta - + + Search by set name, code, type, or release date + + + + How to use custom card art Cómo usar arte de carta personalizada. - + Hints Consejos - + Note Nota - + Sorting by column allows you to find a set while not changing set priority. Ordenar por columna te permite encontrar una edición sin cambiar la prioridad de edición. - + To enable ordering again, click the column header until this message disappears. Para habilitar el orden de nuevo, haga click en el encabezado de la columna hasta que este mensaje deje de aparecer. - + Use the current sorting as the set priority instead Usa el filtro actual como la prioridad de edición - + Sorts the set priority using the same column Ordena la prioridad de edición usando la misma columna - + Manage sets Mantenedor de ediciones @@ -11150,93 +14732,85 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas ZoneViewWidget - + Search by card name (or search expressions) Buscar por nombre de carta (o buscar expresiones) - + Ungrouped Desagrupado - + Group by Type Agrupar por tipo - + Group by Mana Value Agrupar por valor de maná - + Group by Color Agrupar por color - + Unsorted Desordenado - + Sort by Name Ordenar por nombre - + Sort by Type Ordenar por tipo - + Sort by Mana Cost Ordenar por coste de maná - + Sort by Colors Ordenar por colores - + Sort by P/T - + Ordenar por F/R - + Sort by Set - + shuffle when closing barajar al cerrar - + pile view vista de la pila - - i18n - - - English - Español (Spanish) - - main - + Connect on startup Conectar al inicio - + Debug to file Depurar a archivo @@ -11575,22 +15149,22 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Show Status Bar - + Mostrar barra de estado Load deck from online service... - + Cargar desde el servicio en línea... Load from website... - + Cargar desde el sitio web... Unload Deck - + Desmontar mazo @@ -11775,7 +15349,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Increment all card counters - + Incrementa contadores en todas las cartas @@ -11900,7 +15474,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Hide Card in Reveal Window - + Ocultar carta en ventana de reveladas @@ -11916,7 +15490,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Toggle Skip Untapping Toggle Untap - + Alterna saltar fase de destapar @@ -11981,22 +15555,22 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Select All Cards in Zone - + Seleccionar todas las cartas en zona Select All Cards in Row - + Seleccionar todas las cartas en fila Select All Cards in Column - + Seleccionar todas las cartas en columna Reveal Selected Cards to All Players - + Muestra cartas seleccionadas a todos los jugadores @@ -12020,6 +15594,16 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Graveyard Cementerio + + + Report Queue + + + + + Moderation + + @@ -12062,7 +15646,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Bottom Cards of Library - + Cartas del fondo de la biblioteca @@ -12102,7 +15686,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Stack Until Found - + Apilar hasta encontrado @@ -12142,7 +15726,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Flip Coin - + Realizar un volado @@ -12152,12 +15736,12 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Shuffle Top Cards of Library - + Barajar cartas superiores de la biblioteca Shuffle Bottom Cards of Library - + Barajar cartas inferiores de la biblioteca @@ -12167,12 +15751,12 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Mulligan (Same hand size) - + Mulligan (Mismo tamaño de mano) Mulligan (Hand size - 1) - + Mulligan (Cartas en mano - 1) @@ -12202,27 +15786,27 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Sort Hand by Name - + Ordenar mano por nombre Sort Hand by Type - + Ordenar mano por tipo Sort Hand by Mana Value - + Ordenar mano por valor de maná Reveal Hand to All Players - + Revelar mano a todos los jugadores Reveal Random Card to All Players - + Revelar al azar una carta a todos los jugadores @@ -12257,32 +15841,32 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Skip Forward - + Saltar hacia adelante Skip Backward - + Saltar hacia atrás Skip Forward by a lot - + Saltar mucho hacia adelante Skip Backward by a lot - + Saltar mucho hacia atrás Play/Pause - + Reproducir/Pausar Toggle Fast Forward - + Alternar adelanto rápido diff --git a/oracle/translations/oracle_es.ts b/oracle/translations/oracle_es.ts index c11c1740a..141c5b26f 100644 --- a/oracle/translations/oracle_es.ts +++ b/oracle/translations/oracle_es.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Introducción - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Este instalador importará la lista de ediciones, cartas y fichas que va a usar Cockatrice. - + Interface language: Idioma de la interfaz: - + Version: Versión: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Seleccionar origen - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Por favor especifica un origen compatible para la lista de sets y cartas. Puedes especificar la URL de donde descargarla o usar un archivo existente de tu ordenador. - + Download URL: URL de descarga: - + Local file: Archivo local: - + Restore default URL Restablecer URL predeterminada - + Choose file... Elegir archivo... - + Load sets file Cargar archivo de ediciones - + Sets file (%1) Sets JSON file (%1) Archivo de ediciones (%1) - - - - - - - + + + + + Error Error - + The provided URL is not valid. La URL suministrada no es válida. - - Downloading (0MB) - Descargando (0MB) - - - + Please choose a file. Por favor elija un archivo. - + Cannot open file '%1'. No se puede abrir el archivo '%1' - - Downloading (%1MB) - Descargando (%1MB) - - - + Network error: %1. Error de red: %1 - + Parsing file Procesando archivo - + Xz extraction failed. Extracción de Xz fallida - + Sorry, this version of Oracle does not support xz compressed files. Lo sentimos, esta versión de Oracle no soporta archivos comprimidos xz - + Failed to open Zip archive: %1. Error al abrir el archivo Zip: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Fallo al extraer el contenido: el Zip contiene más de un archivo. - + Zip extraction failed: %1. Error al extraer el contenido del Zip: %1. - + Sorry, this version of Oracle does not support zipped files. Lo sentimos, esta versión de Oracle no soporta archivos comprimidos. - + Failed to interpret downloaded data. No se pudieron interpretar los datos descargados. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? ¿Prefieres descargar el archivo sin comprimir? - + + The file was retrieved successfully, but it does not contain any sets data. El archivo fue cargado correctamente pero no contiene datos sobre ningún set. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Guardar la base de datos de spoilers - + XML; spoiler database (*.xml) XML; base de datos de spoilers (*.xml) - + spoiler spoiler - + Spoilers import Importar spoilers - + Please specify a compatible source for spoiler data. Por favor elija un origen compatible para los datos de los spoilers. - + Download URL: URL de descarga: - + Local file: Archivo local: - + Restore default URL Restaurar URL por defecto - + Choose file... Elegir archivo... - + The spoiler database will be saved at the following location: La base de datos de los spoilers será guardada en la siguiente ubicación: - + Save to a custom path (not recommended) Guardar en una ruta personalizada (no recomendado) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Guardar base de datos de fichas - + XML; token database (*.xml) XML; base de datos de fichas (*.xml) - + tokens fichas - + Tokens import Importar tokens - + Please specify a compatible source for token data. Por favor elija un origen compatible para los datos de las fichas. - + Download URL: URL de descarga: - + Local file: Archivo local: - + Restore default URL Restablecer URL predeterminada - + Choose file... Elegir archivo... - + The token database will be saved at the following location: La base de datos de las fichas será guardada en la siguiente ubicación: - + Save to a custom path (not recommended) Guardar en una ruta personalizada (no recomendado) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Set dedicado para tokens @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Importador de Oracle @@ -292,22 +306,22 @@ OutroPage - + Finished Completado - + The wizard has finished. El asistente ha terminado. - + You can now start using Cockatrice with the newly updated cards. Ahora puedes usar Cockatrice con las cartas actualizadas. - + If the card databases don't reload automatically, restart the Cockatrice client. Si las bases de datos de cartas no se recargan automáticamente, reinicia el cliente de Cockatrice. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Error - + + No set has been imported. Ningún set ha sido importado. - + Sets imported Sets importados - - A cockatrice database file of %1 MB has been downloaded. - Se ha descargado un archivo de base de datos cockatrice de %1 MB. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: Las siguientes ediciones han sido encontradas: - + Press "Save" to store the imported cards in the Cockatrice database. Pulsa "Guardar" para guardar las cartas importadas en la base de datos de Cockatrice. - + The card database will be saved at the following location: La base de datos de cartas ha sido guardada en la siguiente ubicación: - + Save to a custom path (not recommended) Guardar en una ruta personalizada (no recomendado) - + &Save &Guardar - + + Import finished: %1 cards. Importación terminada: %1 cartas. - + + %1: %2 cards imported %1: %2 cartas importadas - + Save card database Guardar base de datos de cartas - + XML; card database (*.xml) XML; base de datos de cartas (*.xml) - + The file could not be saved to %1 El archivo no ha podido ser guardado en %1 @@ -414,8 +431,9 @@ - Downloading (0MB) - Descargando (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,7 +605,7 @@ i18n - + English Español (Spanish) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Sólo ejecutar en modo spoiler - + Run in no-confirm background mode Ejecutar en modo del segundo plano sin confirmación From 33df0916a8d17950bbf79a4f3d81b9e57d04ad2b Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:51:01 +0200 Subject: [PATCH 057/247] Updates for project Cockatrice and language de (#7555) * Translate oracle_en@source.ts in de [Manual Sync] 93% of minimum 5% translated source file: 'oracle_en@source.ts' on 'de'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in de [Manual Sync] 67% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'de'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_de.ts | 7458 ++++++++++++++++------ oracle/translations/oracle_de.ts | 204 +- 2 files changed, 5632 insertions(+), 2030 deletions(-) diff --git a/cockatrice/translations/cockatrice_de.ts b/cockatrice/translations/cockatrice_de.ts index 51bee3cd4..265c29cdc 100644 --- a/cockatrice/translations/cockatrice_de.ts +++ b/cockatrice/translations/cockatrice_de.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Zähler &setzen... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Zähler setzen - + New value for counter '%1': Neuer Wert für den Zähler '%1': @@ -36,66 +36,115 @@ AbstractTabDeckEditor - + Open in new tab In neuem Reiter öffnen - + Are you sure? Sind Sie sicher? - + The decklist has been modified. Do you want to save the changes? Die Deckliste wurde bearbeitet. Änderungen speichern? - - - - - - + + + + + + Error Fehler - + Could not open deck at %1 Deck an Stelle %1 konnte nicht geöffnet werden - + Could not save remote deck Remote-Deck konnte nicht gespeichert werden - - + + The deck could not be saved. Please check that the directory is writable and try again. Das Deck konnte nicht abgespeichert werden. Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen Sie es erneut. - + Save deck Deck speichern - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. Das Deck konnte nicht abgespeichert werden. - + There are no cards in your deck to be exported Es gibt keine Karten in Ihrem Deck zum Exportieren + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen AdminNotesDialog - + Update Notes Aktualisierungsnotizen - + Admin Notes for %1 Administrator Notizen für %1 @@ -138,218 +187,304 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen AppearanceSettingsPage - + seconds Sekunden - + Error Fehler - + Could not create themes directory at '%1'. Das Themeverzeichnis '%1' konnte nicht erstellt werden. - + Theme settings Darstellungseinstellungen - + Current theme: Aktuelles Theme: - + Open themes folder Öffne Themeverzeichnis - + Home tab background source: Startseitenhintergrund-Quelle: - + Home tab background shuffle frequency: Startseitenhintergrund Wechselfrequenz: - + Disabled Deaktiviert - + Menu settings Einstellungsmenü - + Show keyboard shortcuts in right-click menus Tastatur-Kürzel anzeigen bei Rechtsklickmenü - + Show game filter toolbar above list in room tab Spiel-Filter Symbolleiste über der Liste im Raum-Tab anzeigen - + Card rendering Kartendarstellung - + Display card names on cards having a picture Kartennamen auch bei Karten mit Bildern darstellen - + Auto-Rotate cards with sideways layout Automatisches Drehen von Karten mit Seitwärts-Ausrichtung - + Override all card art with personal set preference (Pre-ProviderID change behavior) Überschreiben aller Kartenbilder mit persönlichen Set-Präferenzen (Vor-AnbieterID Wechselverhalten) - + Light - + Hell - + Dark - + Dunkel - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector Priorisieren von Sets, in welchen Karten des ausgewählten Decks vorhanden sind, nach oben in der Druckauswahl - + Scale cards on mouse over Karten beim Darüberfahren mit der Maus vergrößern - + Use rounded card corners Abgerundete Kartenecken verwenden - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand Minimaler Überschneidungsanteil der Karten auf dem Stapel und in der vertikalen Hand - + Maximum initial height for card view window: Maximale Starthöhe für das Kartenansichtsfenster - - + + rows Reihen - + Maximum expanded height for card view window: Maximale ausgeklappte Höhe für das Kartenansichtsfenster: - + Card counters Kartenzähler - + Counter %1 Zähler %1 - + Hand layout Handdarstellung - + Display hand horizontally (wastes space) Hand horizontal anzeigen (verschwendet Platz) - + Enable left justification Linksbündige Ausrichtung aktivieren - + Table grid layout Spielfeldraster - + Invert vertical coordinate Vertikale Koordinate umkehren - + Minimum player count for multi-column layout: Mindestspieleranzahl für mehrspaltige Anordnung: - + Maximum font size for information displayed on cards: Maximale Schriftgröße für die Anzeige von Informationen auf Karten: @@ -357,12 +492,12 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen ArchidektApiResponseDeckDisplayWidget - + Back to results Zurück zu den Ergebnissen - + Open Deck in Deck Editor Deck im Deck-Editor öffnen @@ -388,112 +523,112 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen BanDialog - + ban &user name &Benutzername - + ban &IP address &IP-Adresse - + ban client I&D Client I&D bannen - + Ban type Art des Banns - + &permanent ban &permanenter Bann - + &temporary ban &temporärer Bann - + &Days: &Tage: - + &Hours: &Stunden: - + &Minutes: &Minuten: - + Duration of the ban Länge des Banns - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Bitte geben Sie den Grund für den Bann ein. Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nicht gesehen werden. - + Please enter the reason for the ban that will be visible to the banned person. Bitte geben Sie den Grund ein, den die gebannte Person sehen kann. - + Redact all messages from this user in all rooms Entferne alle Nachrichten dieses Nutzers in allen Räumen - + &OK &OK - + &Cancel &Abbrechen - + Ban user from server Benutzer vom Server bannen - - - - + + + + Error Fehler - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Bitte wählen Sie einen Namens-, Adress- oder Client-ID-Bann oder eine Kombination aus diesen. - + You must have a value in the name ban when selecting the name ban checkbox. Wenn Sie das Namensbannkästchen auswählen, muss ein Wert für den Namen eingegeben werden. - + You must have a value in the ip ban when selecting the ip ban checkbox. Wenn Sie das Adressbannkästchen auswählen, muss ein Wert für die IP eingegeben werden. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Wenn Sie das Client-ID-Kästchen auswählen, muss ein Wert für die Client-ID eingegeben werden. @@ -501,22 +636,22 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Keine Antwort vom Veröffentlichungsaktualisierungsserver erhalten. - + Invalid reply received from the release update server. Ungültige Antwort vom Veröffentlichungsaktualisierungsserver erhalten. - + No reply received from the file update server. Keine Antwort vom Dateiaktualisierungsserver erhalten. @@ -524,7 +659,20 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -532,22 +680,22 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -555,65 +703,209 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardDatabaseModel - + Name Name - + Sets Editionen - + Mana cost Manakosten - + Card type Kartentyp - + P/T S/W - + Color(s) Farbe(n) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -723,22 +1015,22 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardInfoFrameWidget - + Image Bild - + Description Beschreibung - + Both Beide - + View transformation Transformation anzeigen @@ -746,22 +1038,22 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardInfoPictureWidget - + View related cards Zugehörige Karten ansehen - + Add card to deck Karte zum Deck hinzufügen - + Mainboard Mainboard - + Sideboard Sideboard @@ -769,27 +1061,27 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardInfoTextWidget - + Name: Name: - + Set: Set: - + Collector Number: Sammlernummer: - + Related cards: Zugehörige Karten: - + Unknown card: Unbekannte Karte: @@ -797,129 +1089,129 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardMenu - + Re&veal to... Zeigen an.... - + &All players &Alle Spieler - + View related cards Verwandte Karten ansehen - + Token: Spielstein: - + All tokens Alle Spielsteine - + &Select All &Alles auswählen - + S&elect Row Reihe auswählen - + S&elect Column Spalte auswählen - + &Play &Spielen - + &Hide &Verstecken - + Play &Face Down Verdeckt ausspielen - + &Tap / Untap Turn sideways or back again &Tappen/ Enttappen - + Skip &untapping Enttappen überspringen - + T&urn Over Turn face up/face down Umdrehen - + &Peek at card face %Kartenvorderseite ansehen - + &Clone &Kopieren - + Attac&h to card... An Karte anhängen - + Unattac&h Lösen - + &Draw arrow... &Pfeil zeichnen - + &Set annotation... &Anmerkung setzen - + Reduce life by power - + Ca&rd counters Kartenzähler - + &Add counter (%1) &Zähler hinzufügen (%1) - + &Remove counter (%1) &Zähler entfernen (%1) - + &Set counters (%1)... &Zähler setzen (%1)... @@ -940,7 +1232,7 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardSizeWidget - + Card Size Kartengröße @@ -1080,6 +1372,24 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic %1's individuelle Zone '%2' + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1109,280 +1419,382 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1390,113 +1802,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1546,7 +1958,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database Kartendatenbank @@ -1562,17 +1974,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Suche nach Kartennamen (oder Suchausdrücken) - + Add card to &maindeck Karte zum &Hauptdeck hinzufügen - + Add card to &sideboard Karte zum &Sideboard hinzufügen @@ -1580,97 +1992,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... Datenbank wird geladen... - + Banner Card Bannerkarte - + Main Type Haupttyp - + Mana Cost Manakosten - + Colors Farben - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing Auswahl der Auflage - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Deck - + Deck &name: Deck&name: - + Banner Card/Tags Visibility Settings Bannerkarte/Sichtbarkeitseinstellungen von Tags - + Show banner card selection menu Bannerkartenauswahlmenü anzeigen - + Show tags selection menu Tagauswahlmenü anzeigen - + &Comments: &Kommentare: - + Group by: Gruppieren nach: - + Format: Format: - + Hash: Hash: - + &Increment number &Zahl erhöhen - + &Decrement number &Zahl verringern - + &Remove row &Reihe entfernen - + Swap card to/from sideboard Karte ins/aus dem Sideboard tauschen @@ -1678,17 +2151,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Filter - + &Clear all filters &Alle Filter entfernen - + Delete selected Auswahl löschen @@ -1696,114 +2169,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor &Deckeditor - + &New deck &Neues Deck - + &Load deck... &Deck laden... - + Load recent deck... Laden der letzten Decks... - + Clear Alles entfernen - + &Save deck &Deck speichern - + Save deck &as... &Deck speichern unter... - + + Share deck... + + + + Load deck from cl&ipboard... Deck aus &Zwischenablage laden... - + Edit deck in clipboard Deck in Zwischenablage bearbeiten - - + + Annotated Kommentiert - - + + Not Annotated Nicht kommentiert - + Save deck to clipboard Deck in Zwischenablage speichern - + Annotated (No set info) Komentiert (keine Setinformationen) - + Not Annotated (No set info) Nicht kommentiert (keine Setinformationen) - + &Print deck... &Deck drucken... - + Load deck from online service... Deck von Onlineservice laden.... - + &Send deck to online service &Deck an Onlineservice senden - + Create decklist (decklist.org) Deckliste erstellen (decklist.org) - + Create decklist (decklist.xyz) Deckliste erstellen (decklist.xyz) - + Analyze deck (deckstats.net) Deck analysieren (deckstats.net) - + Analyze deck (tappedout.net) Deck analysieren (tappedout.net) - + &Close &Schließen @@ -1811,7 +2289,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector Auflagenauswahl @@ -1819,110 +2297,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Spoiler aktualisieren - + Success Erfolgreich - + Download URLs have been reset. Download-URLs wurden zurückgesetzt. - + Add URL URL hinzufügen - - + + URL: URL - - + + Edit URL URL bearbeiten - + Add New URL Neue URL hinzufügen - + Remove URL URL entfernen - + Updating... Aktualisiere... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Pfad auswählen - + URL Download Priority URL Downloadpriorität - + Spoilers Spoiler - + Download Spoilers Automatically Lade Spoiler automatisch herunter - + Spoiler Location: Spoiler Verzeichnis: - + Last Change Letzte Änderung - + Spoilers download automatically on launch Spoiler werden beim Start automatisch heruntergeladen - + Press the button to manually update without relaunching Drücke den Knopf um manuell zu aktualisieren ohne neu zu starten - + Do not close settings until manual update is complete Schließen Sie die Einstellungen nicht solange die manuelle Aktualisierung nicht abgeschlossen ist. - + Download card pictures on the fly Kartenbilder dynamisch herunterladen - + How to add a custom URL Wie eine benutzerdefinierte URL hinzugefügt wird - + Reset Download URLs Download-URLs zurücksetzen @@ -1930,32 +2450,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo Rückgängig machen - + Redo Wiederholen - + Undo/Redo history Rückgängig machen/Wiederholen Verlauf - + Click on an entry to revert to that point in the history. Einen Eintrag anklicken um zu diesem Zeitpunkt im Verlauf zurückzukehren - + [redo] [wiederholen]  - + [undo] [rückgängig machen]  @@ -1963,27 +2483,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Anzahl - + Set Festlegen - + Number Nummer - + Provider ID ID des Anbieters - + Card Karte @@ -2004,17 +2524,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Modus: Exakte Übereinstimmung - + Mode: Includes Modus: Enthält - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) Farbidentitätsfiltermodus (UND/ODER/NICHT Verknüpfungen der Filter) @@ -2022,7 +2547,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Tags bearbeiten ... @@ -2030,62 +2555,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Decktagsmanager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: Verwalte Decktags. Wähle Tags nach Bedarf aus oder füge neue hinzu: - + Add a new tag (e.g., Aggro️) Neuen Tag hinzufügen (bspw. Aggro) - + Add Tag Tag hinzufügen - + Filter tags... Tags filtern... - + OK OK - + Edit default tags Standardtags bearbeiten - + Cancel Abbrechen - + Invalid Input Ungültige Eingabe - + Tag name cannot be empty! Tagname kann nicht leer sein! - + Duplicate Tag Tag duplizieren - + This tag already exists. Dieser Tag existiert bereits. @@ -2093,94 +2618,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card Bannerkarte - + Open in deck editor Im Deckeditor öffnen - + + Share deck... + + + + Edit Tags Tags bearbeiten - + Rename Deck Deck umbenennen - + Save Deck to Clipboard Deck in die Zwischenablage speichern - + Annotated Kommentiert - + Annotated (No set info) Komentiert (keine Setinformationen) - + Not Annotated Nicht kommentiert - + Not Annotated (No set info) Nicht kommentiert (keine Setinformationen) - + Rename File Datei umbenennen - + Delete File Datei löschen - + Set Banner Card Bannerkarte setzen - - + + New name: Neuer Name: - - + + Rename deck + + + + + Rename file + + + + + Error Fehler - + Rename failed Umbenennen fehlgeschlagen - + Delete file Datei löschen - + Are you sure you want to delete the selected file? Sind Sie sicher, dass die ausgewählte Datei gelöscht werden soll? - + Delete failed Löschen fehlgeschlagen @@ -2188,57 +2728,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" Deck umbenennen von "%1" zu "%2" - + Updated comments (was %1 chars, now %2 chars) Kommentare aktualisiert (davor %1 Zeichen, jetzt %2 Zeichen) - + Set banner card to %1 (%2) Bannerkarte setzen als %1 (%2) - + + Set playmat to %1 + + + + Tags changed Tags geändert - + Set format to %1 Format setzen zu %1 - + Added (%1): %2 (%3) %4 (%1): %2 (%3) %4 hinzugefügt - + Moved to %1 1 × "%2" (%3) Nach %1 1 × "%2" (%3) bewegt - + Removed "%1" (all copies) "%1" entfernt (alle Kopien) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Hinzugefügt - + Removed Entfernt @@ -2260,88 +2845,116 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Deck laden... - + Load remote deck... Deck vom Server laden... - + Load from clipboard... Aus der Zwischenablage laden... - + Load from website... Von Internetseite laden.... - + Unload deck Deck entladen - + Ready to start Bereit zum Start - + Force start Zwangsstart - + Sideboard unlocked Sideboard entsperrt - + Sideboard locked Sideboard gesperrt - - + + Error Fehler - + The selected file could not be loaded. Die gewählte Datei konnte nicht geladen werden. - + Deck is greater than maximum file size. Deck übersteigt die maximale Dateigröße. - + Are you sure you want to force start? This will kick all non-ready players from the game. Sind Sie sicher, dass sie den Start erzwingen wollen? Dies wirft alle nicht bereiten Spieler aus dem Spiel. - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion Deckformatkonvertierung - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? @@ -2350,7 +2963,7 @@ Tags können nur zu Decks im .cod Format hinzugefügt werden. Möchten sie das Deck ins .cod Format konvertieren? - + Remember and automatically apply choice in the future Auswahl speichern und automatisch in der Zukunft anwenden @@ -2363,143 +2976,143 @@ Möchten sie das Deck ins .cod Format konvertieren? Lade herunter... - + Known Hosts Bekannte Hosts - + Delete the currently selected saved server Den derzeit gespeicherten Server löschen - + Refresh the server list with known public servers Aktualisiere die Serverliste der bekannten öffentlichen Server - + New Host Neuer Host - + Name: Name: - + &Host: &Server: - + &Port: &Port: - + Player &name: Spieler&name: - + P&assword: P&asswort: - + &Save password Passwort &speichern - + A&uto connect a&utomatisch verbinden - + Automatically connect to the most recent login when Cockatrice opens Automatisch mit dem letzten Login verbinden, wenn sich Cockatrice öffnet - + If you have any trouble connecting or registering then contact the server staff for help! Falls Probleme mit der Verbindung oder Registrierung bestehen, kontaktieren Sie das Personal für Hilfe! - - + + Webpage Webseite - + Reset Password Passwort zurücksetzen - + Forgot password? Passwort vergessen? - + &Connect &Verbinden... - + Server Server - + Login Login - + Server Contact Serverkontakt - + Connect to Server Verbinde zum Server - + Server URL Server URL - + Communication Port Kommunikationsport - + Unique Server Name Einzigartiger Servername - + Connection Warning Verbindungswarnung - + You need to name your new connection profile. Sie müssen Ihr neues Verbindungsprofil benennen. - + Connect Warning Verbindungswarnung - + The player name can't be empty. Der Spielername kann nicht leer sein. @@ -2507,122 +3120,122 @@ Möchten sie das Deck ins .cod Format konvertieren? DlgCreateGame - + Re&member settings Einstellungen speichern - + &Description: &Beschreibung: - + P&layers: &Spieler: - + General Allgemeines - + Game type Spieltyp - + &Password: &Passwort: - + Only &buddies can join Nur &Freunde können teilnehmen - + Only &registered users can join Nur &registrierte Benutzer können teilnehmen - + Joining restrictions Teilnahmebedingungen - + &Spectators can watch Zuschauer erlauben - + Spectators &need a password to watch Zuschauer benötigen das Passwort - + Spectators can &chat Zuschauer können &chatten - + Spectators can see &hands Zuschauer sehen die Hände - + Create game as spectator Spiel als Zuschauer erstellen - + Spectators Zuschauer - + Starting life total: Anfangslebenspunktzahl - + Open decklists in lobby Offene Decklisten in Lobby - + Create game as judge Spiel als Richter erstellen - + Game setup options Spielaufsetzungsoptionen - + &Clear Löschen - + Create game Spiel erstellen - + Game information &Spielinformationen - + Error Fehler - + Server error. Serverfehler. @@ -2630,97 +3243,97 @@ Möchten sie das Deck ins .cod Format konvertieren? DlgCreateToken - + &Name: &Name: - + Token Spielstein - + C&olor: &Farbe: - + white weiß - + blue blau - + black schwarz - + red rot - + green grün - + multicolor mehrfarbig - + colorless farblos - + &P/T: &Kampfwerte: - + &Annotation: &Hinweis: - + &Destroy token when it leaves the table Spielstein &zerstören, wenn er das Spielfeld verlässt - + Create face-down (Only hides name) Verdeckt erstellen (versteckt nur den Namen) - + Token data Spielstein-Daten - + Show &all tokens &Alle möglichen Spielsteine zeigen - + Show tokens from this &deck Spielsteine dieses &Decks zeigen - + Choose token from list Spielstein aus Liste auswählen - + Create token Spielstein erstellen @@ -2728,53 +3341,53 @@ Möchten sie das Deck ins .cod Format konvertieren? DlgDefaultTagsEditor - + Edit Tags Tags bearbeiten - + Add Hinzufügen - + Confirm Bestätigen - + Cancel Abbrechen - + Enter a tag and press Enter Tag eingeben und Eingabetaste drücken - - + + ✖ ✖ - + Invalid Input Ungültige Eingabe - + Tag name cannot be empty! Tagname kann nicht leer sein! - + Duplicate Tag Tag duplizieren - + This tag already exists. Dieser Tag existiert bereits @@ -2783,7 +3396,7 @@ Möchten sie das Deck ins .cod Format konvertieren? DlgEditAvatar - + No image chosen. Kein Bild gewählt. @@ -2795,27 +3408,32 @@ To remove your current avatar, confirm without choosing a new image. Um Ihren derzeitigen Avatar zu entfernen, bestätigen Sie, ohne ein neues Bild zu wählen. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Durchsuchen... - + Change avatar Avatar wechseln - + Open Image Bild öffnen - + Image Files (*.png *.jpg *.bmp) Bild-Dateien (*.png *.jpg *.bmp) - + Invalid image chosen. Ungültiges Bild gewählt. @@ -2841,38 +3459,38 @@ Um Ihren derzeitigen Avatar zu entfernen, bestätigen Sie, ohne ein neues Bild z DlgEditPassword - + Old password: Altes Passwort: - + New password: Neues Passwort: - + Confirm new password: Bestätige neues Passwort: - + Change password Passwort ändern - - + + Error Fehler - + Your password is too short. Das Passwort ist zu kurz. - + The new passwords don't match. Die neuen Passwörter stimmen nicht überein. @@ -2880,93 +3498,93 @@ Um Ihren derzeitigen Avatar zu entfernen, bestätigen Sie, ohne ein neues Bild z DlgEditTokens - + &Name: &Name: - + C&olor: &Farbe: - + white weiß - + blue blau - + black schwarz - + red rot - + green grün - + multicolor mehrfarbig - + colorless farblos - + &P/T: &Kampfwerte: - + &Annotation: &Hinweis: - + Token data Spielstein-Daten - - + + Add token Spielstein hinzufügen - + Remove token Spielstein entfernen - + Edit custom tokens Benutzerdefinierte Spielsteine bearbeiten - + Please enter the name of the token: Bitte geben Sie den Namen des Spielsteins ein: - + Error Fehler - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Es existiert bereits eine Karte oder ein Spielstein mit dem gewählten Namen. @@ -3045,8 +3663,9 @@ Aktivieren Sie die Edition „Token" im „Editionen verwalten...“ Menü - Hide games that have started - Bereits begonnene Spiele ausblenden + Hide started games + Hide games that have started + @@ -3081,8 +3700,9 @@ Aktivieren Sie die Edition „Token" im „Editionen verwalten...“ Menü - &Creator name: - &Name des Erstellers: + &Host name: + &Creator name: + @@ -3148,52 +3768,52 @@ Aktivieren Sie die Edition „Token" im „Editionen verwalten...“ Menü DlgForgotPasswordChallenge - + Reset Password Challenge Warning Setze Passwortherausforderungswarnung zurück - + A problem has occurred. Please try to request a new password again. Ein Problem ist aufgetreten. Bitte erneut ein neues Passwort anfordern. - + Enter the information of the server and the account you'd like to request a new password for. Gebe die Informationen des Servers und Kontos, für das ein neues Passwort angefordert werden soll, an. - + &Host: &Host: - + &Port: &Port: - + Player &name: Spieler&name: - + Email: E-Mail: - + Reset Password Challenge Setze Passwortherausforderung zurück - + Reset Password Challenge Error Setze Passwortherausforderungsfehler zurück - + The email address can't be empty. Die E-Mail-Adresse kann nicht leer sein. @@ -3201,37 +3821,37 @@ Aktivieren Sie die Edition „Token" im „Editionen verwalten...“ Menü DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Gebe die Informationen des Servers, für den ein neues Passwort angefordert werden soll, an. - + &Host: &Host: - + &Port: &Port: - + Player &name: Spieler&name: - + Reset Password Request Passwortrücksetzungsanfrage - + Reset Password Error Passwortrücksetzungsfehler - + The player name can't be empty. Der Spielername kann nicht leer sein. @@ -3239,94 +3859,127 @@ Aktivieren Sie die Edition „Token" im „Editionen verwalten...“ Menü DlgForgotPasswordReset - + Reset Password Warning Passwortrücksetzungswarnung - + A problem has occurred. Please try to request a new password again. Ein Problem ist aufgetreten. Bitte erneut ein neues Passwort anfordern. - + Enter the received token and the new password in order to set your new password. Gebe den erhaltenen Token und das neue Passwort ein, um das neue Passwort zu setzen. - + &Host: &Host: - + &Port: &Port: - + Player &name: Spieler&name: - + Token: Spielstein: - - + + New Password: Neues Passwort: - + Reset Password Passwort zurücksetzen - + The player name can't be empty. Der Spielername kann nicht leer sein. - - - - + + + + Reset Password Error Passwortrücksetzungsfehler - + The token can't be empty. Der Spielstein kann nicht leer sein. - + The new password can't be empty. Das neue Passwort kann nicht leer sein. - + Error Fehler - + Your password is too short. Das Passwort ist zu kurz. - + The passwords do not match. Die Passwörter stimmen nicht überein. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck Deck laden @@ -3418,166 +4071,567 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: Spieler: - + General Allgemein - + Starting life total: Lebenspunkte zum Spielstart: - + Game setup options Spieleinstellungen - + Remember settings Einstellungen merken - + Local game options Lokale Spieleinstellungen + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Kartenname (oder Suchbezeichnung) - + Number of hits: Anzahl der Treffer: - + Auto play hits Automatisches Ausspielen von Treffern - + Put top cards on stack until... Oberste Karten auf den Stapel legen bis... - + No cards matching the search expression exists in the card database. Proceed anyways? Keine Karten, die den Suchbezeichnungen entsprechen, existieren in der Kartendatenbank. Trotzdem fortfahren? - + Cockatrice Basilisk - + Invalid filter Ungültiger Filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Geben Sie Ihre Informationen und die Informationen des Servers, bei dem Sie sich registrieren wollen, an. Die E-Mail-Adresse wird zur Accountverifikation genutzt. - + &Host: &Host: - + &Port: &Port: - + Player &name: Spieler&name: - + P&assword: P&asswort - + Password (again): Passwort (Wiederholung) - + Email: E-Mail: - + Email (again): E-Mail (Wiederholung) - + Country: Land: - + Undefined Undefiniert - + Real name: Echter Name: - + Register to server Auf Server registrieren - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Registrierungswarnung - + Your password is too short. Das Passwort ist zu kurz. - + Your passwords do not match, please try again. Ihre Passwörter stimmen nicht überein, bitte versuchen Sie es erneut. - + Your email addresses do not match, please try again. Ihre Mailadressen stimmen nicht überein, bitte versuchen Sie es erneut. - + The player name can't be empty. Der Spielername kann nicht leer sein. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3643,46 +4697,12 @@ Die E-Mail-Adresse wird zur Accountverifikation genutzt. DlgSettings - + Unknown Error loading card database Unbekannter Fehler beim Laden der Kartendatenbank - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Ihre Kartendatenbank ist ungültig. - -Cockatrice funktioniert möglicherweise mit einer ungültigen Kartendatenbank nicht richtig. - -Sie müssen Oracle unter Umständen nochmals ausführen um Ihre Kartendatenbank zu aktualisieren. - -Möchten Sie Ihren Speicherort der Datenbank aktualisieren?? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Ihre Kartendatenbank-Version ist zu alt. - -Dies kann zu Problemen beim Laden von Karteninformationen oder Bildern führen. - -Normalerweise kann dies durch einen erneuten Start von Oracle, um die Kartendatenbank zu aktualisieren, behoben werden. - -Möchten Sie Ihren Speicherort der Datenbank aktualisieren? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3695,7 +4715,7 @@ Bitte erstellen Sie ein Ticket auf https://github.com/Cockatrice/Cockatrice/issu Möchten Sie die Einstellung des Datenbankspeicherorts ändern? - + File Error loading your card database. Would you like to change your database location setting? @@ -3704,7 +4724,7 @@ Would you like to change your database location setting? Möchten Sie Ihren Speicherort der Datenbank aktualisieren? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3713,7 +4733,7 @@ Would you like to change your database location setting? Möchten Sie Ihren Speicherort der Datenbank aktualisieren? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3726,77 +4746,208 @@ Bitte erstellen Sie ein Ticket auf https://github.com/Cockatrice/Cockatrice/issu Möchten Sie die Einstellung des Datenbankspeicherorts ändern? - - - + + + Error Fehler - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Der Pfad zu Ihrem Deckordner ist ungültig. Möchten Sie zurückgehen und den korrekten Pfad einstellen? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Der Pfad zu Ihrem Kartenbilderordner ist ungültig. Möchten Sie zurückgehen und den korrekten Pfad einstellen? - + Settings Einstellungen - + + Search settings... + + + + + OK + + + + General Allgemeines - + Appearance Erscheinungsbild - + User Interface Benutzeroberfläche - + Storage - + Card Sources Kartenquellen - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Töne - + Shortcuts Tastaturkürzel + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check Kartenaktualisierungsüberprüfung - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. @@ -3805,27 +4956,27 @@ Bitte wählen Sie aus, wie der Kartendatenbankaktualisierer ausgeführt werden s Sie können das Verhalten jederzeit im "Allgemein"-Tab der Einstellungen ändern. - + Run in foreground Im Vordergrund ausführen - + Run in background Im Hintergrund ausführen - + Run in background and always from now on Immer im Hintergrund ausführen - + Don't prompt again and don't run Nicht erneut fragen und nicht ausführen - + Don't run this time Dieses Mal nicht ausführen @@ -3833,17 +4984,17 @@ Sie können das Verhalten jederzeit im "Allgemein"-Tab der Einstellung DlgTipOfTheDay - + Next Vor - + Previous Zurück - + Tip of the Day Tipp des Tages @@ -3851,166 +5002,176 @@ Sie können das Verhalten jederzeit im "Allgemein"-Tab der Einstellung DlgUpdate - + Current release channel Momentaner Veröffentlichungskanal - + Reinstall Neu installieren - + Cancel Download Download abbrechen - + Open Download Page Downloadseite öffnen - + Check for Client Updates Auf Clientaktualisierungen prüfen - - - - + + + + Error Fehler - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Ihre Version von Cockatrice hat keine SSL-Unterstützung, dadurch können Sie keine Aktualisierungen automatisch herunterladen! Bitte besuchen Sie die Downloadseite, um Cockatrice manuell zu aktualisieren. - + Downloading update: %1 Lade Aktualisierung herunter: %1 - + Checking for updates... Suche nach Aktualisierungen... - + Finished checking for updates Suche nach Aktualisierungen abgeschlossen - + No Update Available Keine Aktualisierung verfügbar - + Cockatrice is up to date! Cockatrice ist aktuell! - + You are already running the latest version available in the chosen release channel. Sie verwenden bereits die aktuellste Version des gewählten Veröffentlichungskanals. - + Current version Momentane Version - + Selected release channel Ausgewählter Veröffentlichungskanal - - + + Update Available Aktualisierung verfügbar - - + + A new version of Cockatrice is available! Eine neue Cockatrice-Version ist verfügbar! - - + + New version Neue Version - - + + Released Veröffentlicht - - + + Changelog Änderungsprotokoll - + Do you want to update now? Möchten Sie jetzt aktualisieren? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Leider hat der automatische Updater keinen kompatiblen Download gefunden. Eventuell müssen sie manuell eine neue Version herunterladen. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Bitte prüfen Sie die <a href='%1'>Veröffentlichungsseite</a> auf unserer Github-Seite und laden Sie die Version für Ihr Betriebssystem herunter. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Aktualisierungsfehler - + An error occurred while checking for updates: Während der Suche nach Aktualisierungen ist ein Fehler aufgetreten: - + An error occurred while downloading an update: Während des Herunterladens der Aktualisierung ist ein Fehler aufgetreten: - + Installing... Installiere... - + Cockatrice is unable to open the installer. Cockatrice kann das Installationsprogramm nicht öffnen. - + Try to update manually by closing Cockatrice and running the installer. Versuchen Sie manuell zu aktualisieren, indem Sie Cockatrice beenden und das Installationsprogramm aufrufen. - + Download location Downloadverzeichnis @@ -4018,17 +5179,17 @@ Eventuell müssen sie manuell eine neue Version herunterladen. DlgViewLog - + Clear log when closing Lösche den Log beim Schließen - + Copy to clipboard In die Zwischenablage kopieren - + Debug Log Fehlerprotokoll @@ -4266,7 +5427,7 @@ Eventuell müssen sie manuell eine neue Version herunterladen. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: Salz: @@ -4274,7 +5435,7 @@ Eventuell müssen sie manuell eine neue Version herunterladen. FilterBuilder - + Type your filter here Gib hier deine Filter ein @@ -4282,45 +5443,88 @@ Eventuell müssen sie manuell eine neue Version herunterladen. FilterDisplayWidget - + Confirm Delete Löschen bestätigen - + Are you sure you want to delete the filter '%1'? Sind Sie sicher, dass Sie den Filter '%1' löschen wollen? - + Delete Failed Löschen fehlgeschlagen - + Failed to delete filter '%1'. Löschen des Filters '%1' fehlgeschlagen. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator Von Spielhost oder Moderator gekickt - + player left the game Spieler hat das Spiel verlassen - + player disconnected from server Spieler hat die Verbindung zum Server getrennt - + reason unknown Grund unbekannt @@ -4328,140 +5532,156 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GameSelector - - - - - - - - - + + + + + + + + + Error Fehler - + Please join the appropriate room first. Bitte betreten Sie erst den entsprechenden Raum. - + Wrong password. Falsches Passwort. - + Spectators are not allowed in this game. In diesem Spiel sind keine Zuschauer zugelassen. - + The game is already full. Das Spiel ist bereits voll. - + The game does not exist any more. Dieses Spiel gibt es nicht mehr. - + This game is only open to registered users. Dieses Spiel kann nur von registrierten Benutzern betreten werden. - + This game is only open to its creator's buddies. Dieses Spiel kann nur von Freunden des Erstellers betreten werden. - + You are being ignored by the creator of this game. Der Ersteller dieses Spiels ignoriert Sie. - + Join Game Spiel beitreten - + Spectate Game Spiel beobachten - + Game Information Spielinformationen - + + Cop&y game link + + + + Join Game as Judge Spiel als Richter beitreten - + Spectate Game as Judge Spiel als Richter zuschauen - + + Join game Spiel beitreten - - Password: - Passwort: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Bitte betreten Sie zuerst den entsprechenden Raum. - + &Filter games Spiele &filtern - + C&lear filter Filter &zurücksetzen - + C&reate E&rstellen - + &Join &Beitreten - + Join as judge Als Richter beitreten - + J&oin as spectator Als Zuschauer beitreten - + Join as judge spectator Als Richter-Zuschauer beitreten - + Games shown: %1 / %2 Angezeigte Spiele: %1 / %2 - + Games Spiele @@ -4502,105 +5722,105 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GamesModel - + >1 day >1 Tag - + %1%2 hr short age in hours %1%2 h%1%2 h - + new neu - + %1%2 min short age in minutes %1%2 min%1%2 min - + password Passwort - + buddies only nur Freunde - + reg. users only nur reg. Benutzer - + open decklists Offene Decklisten - - + + can chat können chatten - + see hands sehen Hände - + can see hands können Hände sehen - + not allowed nicht erlaubt - + Room Raum - + Age Alter - + Description Beschreibung - - Creator - Ersteller + + Host + - + Type Typ - + Restrictions Bedingungen - + Players Spieler - + Spectators Zuschauer @@ -4608,158 +5828,275 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GeneralSettingsPage - + Reset all paths Alle Pfade zurücksetzen - + All paths have been reset Alle Pfade wurden zurückgesetzt - - - - - - - + + + + English + Deutsch (German) + + + + + + + + + Choose path Pfad auswählen - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Sprache: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Pfade (Anpassungen im portablen Modus deaktiviert) - + Paths Pfade - + How to help with translations Wie man beim Übersetzen helfen kann - + Decks directory: Verzeichnis mit Decklisten: - + Filters directory: Filterverzeichnis: - + Replays directory: Verzeichnis mit aufgezeichneten Spielen: - + Pictures directory: Verzeichnis mit Bilddateien: - + Card database: Kartendatenbank: - + Custom database directory: Verzeichnis für benutzerdefinierte Datenbank: - + Token database: Spielsteindatenbank: - + Update channel Aktualisierungskanal - + Check for client updates on startup Nach Aktualisierungen des Klienten beim Start der Anwendung suchen - + Check for card database updates on startup Auf Kartendatenbankaktualisierungen beim Anwendungsstart prüfen - + Don't check Nicht überprüfen - + Prompt for update Zu Aktualisierungen auffordern - + Always update in the background Immer im Hintergrund aktualisieren - + Check for card database updates every Auf Kartendatenbankaktualisierung prüfen alle - + days Tage - + Notify if a feature supported by the server is missing in my client Benachrichtigung wenn ein vom Server unterstütze Funktion in meinem Client fehlt - + Automatically run Oracle when running a new version of Cockatrice Starte Oracle automatisch, wenn eine neue Version von Cockatrice gestartet wird - + Show tips on startup Zeige Tipps beim Start - + Last update check on %1 (%2 days ago) Letzte Aktualisierungsprüfung am %1 (vor %2 Tagen) @@ -4767,47 +6104,47 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GraveyardMenu - + &Graveyard &Friedhof - + &View graveyard &Friedhof ansehen - + &Move graveyard to... & Friedhof nach ... bewegen - + &Top of library &Oben in der Bibliothek - + &Bottom of library &Unten in der Bibliothek - + &All players &Alle Spieler - + &Hand &Hand - + &Exile &Exil - + Reveal random card to... Zufällige Karte vorzeigen für ... @@ -4901,59 +6238,353 @@ Eventuell müssen sie manuell eine neue Version herunterladen. Zufällige Karten an ... vorzeigen + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck Neues Deck erstellen - + Browse Decks Decks durchsuchen - + Browse Card Database Kartendatenbank durchsuchen - + Browse EDHRec EDHRec durchsuchen - + Browse Archidekt Archidekt durchsuchen - + View Replays Wiederholung ansehen - + Quit Verlassen - + Connecting... Verbinden... - + Connect Verbinden - + Play Spielen + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5171,273 +6802,294 @@ Eventuell müssen sie manuell eine neue Version herunterladen. MainWindow - - + + Player %1 Spieler %1 - + Load replay Aufgezeichnetes Spiel laden - + About Cockatrice Über Cockatrice - + Version Version - + Cockatrice Webpage Cockatrice Webseite - + Project Manager: Projektmanager: - + Past Project Managers: frühere Projektmanager: - + Developers: Entwickler: - + Our Developers unsere Entwickler - + Help Develop! Hilf bei der Entwicklung! - + Translators: Übersetzer: - + Our Translators Unsere Übersetzer - + Help Translate! Hilf bei der Übersetzung! - + Support: Unterstützung: - + Report an Issue Problem melden - + Troubleshooting Fehlersuche - + F.A.Q. F.A.Q. - - + + Error Fehler - + &Connect... &Verbinden... - + &Disconnect Verbindung &trennen - + Start &local game... &Lokales Spiel starten... - + &Watch replay... &Aufgezeichnetes Spiel abspielen... - + &Full screen &Vollbild - + &Register to server... Auf Server &registrieren... - + &Restore password... Passwort wiede&rherstellen... - + &Settings... &Einstellungen... - + &Exit &Beenden - + A&ctions Aktionen - + &Cockatrice &Cockatrice - + C&ard Database K&artendatenbank - + &Manage sets... Editionen &verwalten... - + Edit custom &tokens... Bearbeite benutzerdefinierte Spiels&teine... - + Open custom image folder Öffne den Ordner für benutzerdefinierte Kartenbilder - + Open custom sets folder Öffne den Ordner für benutzerdefinierte Sets - + Add custom sets/cards Benutzerdefinierte Karten/Sets hinzufügen - + Reload card database Neu laden der Kartendatenbank - + Tabs Reiter - + &Help &Hilfe - + &About Cockatrice &Über Cockatrice - + &Tip of the Day &Tipp des Tages - + Check for Client Updates Auf Clientaktualisierungen prüfen - + Check for Card Updates... Nach Kartenaktualisierungen suchen... - + Check for Card Updates (Automatic) Prüfe auf Kartenaktualisierungen (automatisch) - + Show Status Bar Statuszeile anzeigen - + View &Debug Log Fehlerprotokoll ansehen - + Open Settings Folder Einstellungsordner öffnen - + + Re-run Onboarding Wizard... + + + + Show/Hide Ein-/Ausblenden - + + Sign-in cancelled + + + + New Version Neue Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Herzlichen Glückwunsch zur Aktualisierung auf Cockatrice %1! Oracle wird gestartet um ihre Kartendatenbank zu aktualisieren. - + Cockatrice installed Cockatrice wurde installiert - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Herzlichen Glückwunsch, dass Sie Cockatrice %1 installiert haben! Oracle wird jetzt gestartet und installiert die initiale Kartendatenbank. - + + Are you sure? + Sind Sie sicher? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Kartendatenbank - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5446,29 +7098,29 @@ Möchten Sie Ihre Kartendatenbank jetzt aktualisieren? Falls Sie unsicher sind oder Cockatrice das erste Mal nutzen, wählen Sie „Ja“ - - + + Yes Ja - - + + No Nein - + Open settings Einstellungen öffnen - + New sets found Neue Editionen gefunden - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5479,22 +7131,22 @@ Setcode(s): %1 Möchten Sie es/sie aktivieren? - + Yes, always enable - + View sets Editionen ansehen - + Welcome Willkommen - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5503,90 +7155,90 @@ Alle Editionen der Kartendatenbank wurden aktiviert. Lesen Sie mehr über das Ändern der Editionsreihenfolge oder die Deaktivierung bestimmter Editionen im „Editionen verwalten...“ Fenster. - + Information Information - + A card database update is already running. Eine Datenbankaktualisierung wird bereits durchgeführt. - + Unable to run the card database updater: Kartendatenbankaktualisierung nicht ausführbar: - + Card database update running. Kartendatenbankaktualisierung läuft. - + Failed to start. The file might be missing, or permissions might be incorrect. Starten fehlgeschlagen. Die Datei könnte fehlen oder die Berechtigung fehlen. - + The process crashed some time after starting successfully. Der Prozess ist einige Zeit nach erfolgreichem Start abgestürzt. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Zeitüberschreitung. Der Prozess hat zu lange nicht geantwortet. Zeitüberschreitung bei letzter waitFor...() Funktion. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Ein Fehler ist beim Schreiben an den Prozess aufgetreten. Beispielsweise könnte der Prozess derzeit nicht ausgeführt werden oder er hat seinen Eingabekanal geschlossen. - + An error occurred when attempting to read from the process. For example, the process may not be running. Ein Fehler ist beim Lesen vom Prozess aufgetreten. Beispielsweise könnte der Prozess derzeit nicht ausgeführt werden. - + Unknown error occurred. Unbekannter Fehler aufgetreten. - + The card database updater exited with an error: %1 Die Kartendatenbankaktualisierung brach mit einem Fehler ab: %1 - - - - - + + + + + Load sets/cards Lade Sets/Karten - + Selected file cannot be found. Die ausgewählte Datei wurde nicht gefunden. - + You can only import XML databases at this time. Im Moment ist es nur möglich XML Datenbanken zu importieren. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Die neuen Sets/Karten wurden erfolgreich hinzugefügt. Cockatrice wird jetzt die Kartendatenbank neu laden. - + Sets/cards failed to import. Set-/Kartenimport fehlgeschlagen. @@ -5791,615 +7443,615 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. MessageLogWidget - + from play vom Spielfeld - + from their graveyard von ihrem Friedhof - + from exile aus dem Exil - + from their hand von ihrer Hand - + the top card of %1's library die oberste Karte von %1s Bibliothek - + the top card of their library die oberste Karte ihrer Bibliothek - + from the top of %1's library oben von %1s Bibliothek - + from the top of their library von ihrer Bibliothek von oben - + the bottom card of %1's library die unterste Karte von %1s Bibliothek - + the bottom card of their library die unterste Karte ihrer Bibliothek - + from the bottom of %1's library von der Unterseite von %1s Bibliothek - + from the bottom of their library von ihrer Bibliothek von unten - + from %1's library aus %1s Bibliothek - + from their library von ihrer Bibliothek - + from sideboard aus dem Sideboard - + from the stack vom Stapel - + from custom zone '%1' von der benutzerdefinierten Zone '%1' - + %1 is now keeping the top card %2 revealed. %1 lässt nun die oberste Karte %2 aufgedeckt. - + %1 is not revealing the top card %2 any longer. %1 lässt die oberste Karte %2 nicht mehr aufgedeckt. - + %1 can now look at top card %2 at any time. %1 kann nun die oberste Karte %2 jederzeit betrachten. - + %1 no longer can look at top card %2 at any time. %1 kann die oberste Karte %2 nicht mehr jederzeit betrachten. - + %1 attaches %2 to %3's %4. %1 legt %2 an %3s %4 an. - + %1 has conceded the game. %1 hat das Spiel aufgegeben. - + %1 has unconceded the game. %1 hat das Spiel doch nicht aufgegeben. - + %1 has restored connection to the game. %1 ist wieder mit dem Spiel verbunden. - + %1 has lost connection to the game. %1 hat die Verbindung zum Spiel verloren. - + %1 points from their %2 to themselves. %1 zeigt von ihrem %2 auf sich selbst. - + %1 points from their %2 to %3. %1 zeigt von ihrem %2 auf %3. - + %1 points from %2's %3 to themselves. %1 zeigt von %2s %3 auf sich selbst. - + %1 points from %2's %3 to %4. %1 zeigt von %2s %3 auf %4. - + %1 points from their %2 to their %3. %1 zeigt von ihrem %2 auf ihren %3. - + %1 points from their %2 to %3's %4. %1 zeigt von ihrem %2 auf %3s %4. - + %1 points from %2's %3 to their own %4. %1 zeigt von %2s %3 auf ihr %4. - + %1 points from %2's %3 to %4's %5. %1 zeigt von %2s %3 auf %4s %5. - + %1 creates a face down token. %1 erzeugt einen umgedrehten Spielstein. - + %1 creates token: %2%3. %1 erstellt Spielstein: %2%3. - + %1 has loaded a deck (%2). %1 hat ein Deck geladen (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 hat ein Deck mit %2 Sideboardkarten geladen (%3). - + %1 destroys %2. %1 zerstört %2. - + a card eine Karte - + %1 gives %2 control over %3. %1 überlässt %2 die Kontrolle über %3. - + %1 puts %2 into play%3 face down. %1 bringt %2%3 verdeckt ins Spiel. - + %1 puts %2 into play%3. %1 bringt %2%3 ins Spiel. - + %1 puts %2%3 into their graveyard face down. %1 legt %2%3 verdeckt in ihren Friedhof - + %1 puts %2%3 into their graveyard. %1 legt %2%3 in ihren Friedhof. - + %1 exiles %2%3 face down. %1 schickt %2%3 verdeckt ins Exil. - + %1 exiles %2%3. %1 schickt %2%3 ins Exil. - + %1 moves %2%3 to their hand. %1 bewegt %2%3 in ihre Hand. - + %1 puts %2%3 into their library. %1 legt %2%3 in ihre Bibliothek. - + %1 puts %2%3 onto the bottom of their library. %1 legt %2%3 unter die Bibliothek. - + %1 puts %2%3 on top of their library. %1 legt %2%3 auf ihre Bibliothek. - + %1 puts %2%3 into their library %4 cards from the top. %1 legt %2%3 in die Bibliothek an %4 Stelle von oben. - + %1 moves %2%3 to sideboard. %1 legt %2%3 in sein Sideboard. - + %1 plays %2%3 face down. %1 bringt %2%3 verdeckt ins Spiel. - + %1 plays %2%3. %1 spielt %2%3 aus. - + %1 moves %2%3 to custom zone '%4' face down. %1 bewegt %2%3 verdeckt zur benutzerdefinierten Zone '%4'. - + %1 moves %2%3 to custom zone '%4'. %1 bewegt %2%3 zur benutzerdefinierten Zone '%4'. - + %1 tries to draw from an empty library %1 versucht von einer leeren Bibliothek zu ziehen - + %1 draws %2 card(s). %1 zieht %2 Karte(n).%1 zieht %2 Karte(n). - + %1 is looking at %2. %1 sieht sich %2 an. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 sieht sich die %4 %3 Karte(n) %2 an.%1 sieht sich die %4 %3 Karte(n) %2 an. - + bottom untersten - + top obersten - + %1 turns %2 face-down. %1 wendet %2 auf die Rückseite. - + %1 turns %2 face-up. %1 wendet %2 auf die Vorderseite. - + The game has been closed. Das Spiel wurde geschlossen. - + The game has started. Das Spiel hat begonnen. - + You are flooding the game. Please wait a couple of seconds. Du überschwemmst das Spiel. Warte bitte einige Sekunden. - + %1 has joined the game. %1 ist dem Spiel beigetreten. - + %1 is now watching the game. %1 schaut nun dem Spiel zu. - + You have been kicked out of the game. Sie wurden aus dem Spiel geworfen. - + %1 has left the game (%2). %1 hat das Spiel verlassen (%2). - + %1 is not watching the game any more (%2). %1 schaut dem Spiel nicht mehr zu (%2). - + %1 is not ready to start the game any more. %1 ist nicht mehr bereit, das Spiel zu starten. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 mischt sein Deck und zieht eine Hand mit %2 Karte(n).%1 mischt sein Deck und zieht eine neue Hand mit %2 Karte(n). - + %1 shuffles their deck and draws a new hand. %1 mischt sein Deck und zieht eine neue Hand. - + You are watching a replay of game #%1. Sie sehen eine Aufzeichnung des Spiels #%1. - + %1 is ready to start the game. %1 ist bereit, das Spiel zu starten. - + cards an unknown amount of cards Karten - + %1 card(s) a card for singular, %1 cards for plural %1 Karte(n)%1 Karte(n) - + %1 lends %2 to %3. %1 verleiht %2 an %3. - + %1 reveals %2 to %3. %1 zeigt %3 %2. - + %1 reveals %2. %1 zeigt %2 offen vor. - + %1 randomly reveals %2%3 to %4. %1 zeigt %4 zufällig %2%3 vor. - + %1 randomly reveals %2%3. %1 zeigt zufällig %2%3 offen vor. - + %1 peeks at face down card #%2. %1 schaut sich die umgedrehte Karte #%2 an. - + %1 peeks at face down card #%2: %3. %1 schaut sich die umgedrehte Karte #%2 an: %3. - + %1 reveals %2%3 to %4. %1 zeigt %4 %2%3 vor. - + %1 reveals %2%3. %1 zeigt %2%3 offen vor. - + %1 reversed turn order, now it's %2. %1 kehrte die Zugreihenfolge um, nun ist es %2. - + reversed umgekehrt - + normal normal - + Heads Kopf - + Tails Zahl - + %1 flipped a coin. It landed as %2. %1 warf eine Münze. Es fiel %2. - + %1 rolls a %2 with a %3-sided die. %1 würfelt eine %2 mit einem %3-seitigen Würfel. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 wirft %2 Münzen. Es ergab %3 mal Kopf und %4 mal Zahl. - + %1 rolls a %2-sided dice %3 times: %4. %1 wirft einen %2-seitigen Würfel %3 mal: %4. - + %1's turn. %1 ist am Zug. - + %1 sets annotation of %2 to %3. %1 versieht %2 mit dem Hinweis %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 setzt Zähler %2 auf %3 (%4%5). - + %1 sets %2 to not untap normally. %1 setzt %2 auf explizites Enttappen. - + %1 sets %2 to untap normally. %1 setzt %2 auf normales Enttappen. - + %1 removes the PT of %2. %1 entfernt die Kampfwerte von %2. - + %1 changes the PT of %2 from nothing to %4. %1 ändert die Kampfwerte von %2 von nichts auf %4. - + %1 changes the PT of %2 from %3 to %4. %1 ändert die Kampfwerte von %2 von %3 auf %4. - + %1 has locked their sideboard. %1 hat ihr Sideboard gesperrt. - + %1 has unlocked their sideboard. %1 hat ihr Sideboard entsperrt. - + %1 taps their permanents. %1 tappt ihre bleibenden Karten. - + %1 untaps their permanents. %1 enttappt ihre bleibenden Karten. - + %1 taps %2. %1 tappt %2. - + %1 untaps %2. %1 enttappt %2. - + %1 shuffles %2. %1 mischt %2. - + %1 shuffles the bottom %3 cards of %2. %1 mischt die untersten %3 Karten von %2. - + %1 shuffles the top %3 cards of %2. %1 mischt die obersten %3 Karten von %2. - + %1 shuffles cards %3 - %4 of %2. %1 mischt die Karten %3 - %4 von %2. - + %1 unattaches %2. %1 löst %2 ab. - + %1 undoes their last draw. %1 legt die zuletzt gezogene Karte zurück. - + %1 undoes their last draw (%2). %1 legt die zuletzt gezogene Karte zurück (%2) @@ -6407,115 +8059,125 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. MessagesSettingsPage - + Word1 Word2 Word3 Wort1 Wort2 Wort3 - + Add New Message Neue Nachricht hinzufügen - + Edit Message Nachricht bearbeiten - + Remove Message Nachricht entfernen - + Add message Nachricht hinzufügen - - + + Message: Nachricht: - + Edit message Nachricht bearbeiten - + Chat settings Chat Einstellungen - + Custom alert words Benutzerdefinierte Benachrichtigungswörter - + Enable chat mentions Chat Erwähnungen aktivieren - + Enable mention completer Autovervollständigung aktivieren - + In-game message macros Makros für Nachrichten in Spielen - + How to use in-game message macros Anleitung zum Verwenden der Makros für Nachrichten in Spielen - + Ignore chat room messages sent by unregistered users Nachrichten von unregistrierten Benutzern im Chatroom ignorieren - + Ignore private messages sent by unregistered users Private Nachrichten von unregistrierten Benutzern ignorieren - + Ignore private messages sent by non-buddy users - - + + Invert text color Textfarbe invertieren - + Enable desktop notifications for private messages Desktop Benachrichtigungen für private Nachrichten aktivieren - + Enable desktop notification for mentions Desktop Benachrichtigungen für Erwähnungen aktivieren - + Enable room message history on join Nachrichtenverlauf beim Betreten eines Raumes aktivieren - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Farbcode in hexadezimal) - + Separate words with a space, alphanumeric characters only Wörter durch Leerzeichen trennen, nur alphanumerische Zeichen @@ -6523,42 +8185,42 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. MoveMenu - + Move to Bewegen nach - + &Top of library in random order &Oben auf der Bibliothek in zufälliger Reihenfolge - + X cards from the top of library... X Karten von oben in der Bibliothek... - + &Bottom of library in random order &Unter die Bibliothek in zufälliger Reihenfolge - + T&able T&isch - + &Hand &Hand - + &Graveyard &Friedhof - + &Exile &Exil @@ -6637,148 +8299,134 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6901,30 +8549,21 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. Ende-des-Zuges-Segment - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - de - - PlayerActions - - - - + + + + grave Friedhof - - - - + + + + exile Exil @@ -7043,16 +8682,272 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. PlayerMenu - + Player "%1" Spieler "%1" - + &Counters &Zähler + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7076,12 +8971,12 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen PrintingSelector - + Display Navigation Buttons Navigationstasten anzeigen - + Printing Selector Druckauswahl @@ -7089,25 +8984,65 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen PrintingSelectorCardOverlayWidget - + Preference Preferenz - + Pin Printing Version festsetzen - + Unpin Printing Version lösen - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards Zugehörige Karten anzeigen + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7138,28 +9073,28 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen PrintingSelectorCardSortingWidget - + Alphabetical Alphabetisch - + Preference Präferenz - + Release Date Erscheinungsdatum - - + + Descending Absteigend - + Ascending Aufsteigend @@ -7175,95 +9110,103 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen PtMenu - + Power / toughness Stärke/Widerstandskraft - + &Increase power &Stärke erhöhen - + &Decrease power &Stärke verringern - + I&ncrease toughness &Widerstandskraft erhöhen - + D&ecrease toughness &Widerstandskraft verringern - + In&crease power and toughness Stärke und Widerstandskraft erhöhen - + Dec&rease power and toughness Stärke und Widerstandskraft verringern - + Increase power and decrease toughness Erhöhe Stärke und verringere Widerstandskraft - + Decrease power and increase toughness Verringere Stärke und erhöhe Widerstandskraft - + Set &power and toughness... Stärke und Widerstandskraft setzen... - + Reset p&ower and toughness Stärke und Widerstandskraft zurücksetzen + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Dienste - + Hide %1 %1 ausblenden - + Hide Others Andere ausblenden - + Show All Alle anzeigen - + Preferences... Einstellungen... - + Quit %1 Schließe %1 - + About %1 Über %1 @@ -7271,17 +9214,17 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen QObject - + Cockatrice card database (*.xml) Cockatrice Kartendatenbank (*.xml) - + All files (*.*) Alle Dateien (*.*) - + Cockatrice replays (*.cor) Aufgezeichnete Cockatrice-Spiele (*.cor) @@ -7301,17 +9244,22 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen Spielsteine - + + Maybeboard + + + + Overwrite Existing File? Existierende Datei überschreiben? - + A .cod version of this deck already exists. Overwrite it? Eine .cod Version dieses Decks existiert bereits. Überschreiben? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7328,7 +9276,7 @@ Sie werden den Set-Manager verwenden müssen, der über Kartendatenbank -> Se Sind sie sicher, dass diese Funktion aktiviert werden soll? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7345,100 +9293,110 @@ Sie können auch den Set-Manager verwenden, um selbstständig die Sortierungsrei Sind sie sicher, dass diese Funktion deaktiviert werden soll? - + Confirm Change Änderung bestätigen + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Speichern - + Save All Alle speichern - + Open Öffnen - + &Yes Ja - + Yes to &All Ja zu &Allem - + &No &Nein - + N&o to All Nein zu Allem - + Abort Abbrechen - + Retry Erneut versuchen - + Ignore Ignorieren - + Close Schließen - + Cancel Abbrechen - + Discard Verwerfen - + Help Hilfe - + Apply Anwenden - + Reset Zurücksetzen - + Restore Defaults Standard wiederherstellen @@ -7446,121 +9404,189 @@ Sind sie sicher, dass diese Funktion deaktiviert werden soll? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Name - + ID ID - + Upload time Änderungsdatum + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Name - + Players Spieler - + Keep Behalten - + Time started Startzeit - + Duration (sec) Länge (Sekunden) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7602,37 +9628,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Räume - + Joi&n Teil&nehmen - + Room Raum - + Description Beschreibung - + Permissions Berechtigungen - + Players Spieler - + Games Spiele @@ -7640,7 +9666,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay S&agen @@ -7648,32 +9674,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Wähle eine Aktion aus der Tabelle - + Hit the key/combination of keys you want to set for this action Gebe die für diese Aktion gewünschte Taste/Tastenkombination ein - + Invalid key Ungültige Taste - + Shortcut already in use by: Tastenkürzel bereits in Verwendung durch: - + Clear Löschen - + Restore default Auf Standard zurücksetzen @@ -7706,56 +9732,92 @@ Fine-tune individual colours in the grid afterwards. Veröffentlichung + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Alle Standard-Tastaturkürzel wiederherstellen - + Do you really want to restore all default shortcuts? Möchten Sie wirklich alle Standard-Tastaturkürzel wiederherstellen? - + Clear all default shortcuts Alle Standard-Tastaturkürzel entfernen - + Do you really want to clear all shortcuts? Möchten Sie wirklich alle Tastaturkürzel entfernen? - + Section: Abschnitt: - + Action: Aktion: - + Shortcut: Tastaturkürzel: - + + Shortcut editor + + + + How to set custom shortcuts Wie werden benutzerdefinierte Tastaturkürzel gesetzt - + Clear all shortcuts Alle Tastaturkürzel entfernen - + Search by shortcut name Suchen nach Name des Tastenkürzels @@ -7811,12 +9873,12 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! SideboardMenu - + &Sideboard &Nebendeck - + &View sideboard &Nebendeck ansehen @@ -7824,27 +9886,27 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! SoundSettingsPage - + Enable &sounds Töne aktivieren - + Current sounds theme: Aktuelles Ton-Theme: - + Test system sound engine Systemsound testen - + Sound settings Toneinstellungen - + Master volume Masterlautstärke @@ -7852,48 +9914,48 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! SpoilerBackgroundUpdater - + Spoilers season has ended Spoiler Saison ist beendet - + Deleting spoiler.xml. Please run Oracle spoiler.xml gelöscht. Bitte führe Oracle aus - - + + Spoilers download failed Spoiler Download fehlgeschlagen - + No internet connection Keine Internetverbindung - + Error Fehler - + Spoilers already up to date Spoiler bereits aktuell - + No new spoilers added Keine neuen Spoiler hinzugefügt - + Spoilers have been updated! Spoiler wurden aktualisiert! - + Last change: Letzte Änderung: @@ -7901,157 +9963,170 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! StableReleaseChannel - + Default Standard - + No reply received from the release update server. Keine Antwort vom Veröffentlichungsaktualisierungsserver erhalten. - + Invalid reply received from the release update server. Ungültige Antwort vom Veröffentlichungsaktualisierungsserver erhalten. - + No reply received from the tag update server. Keine Antwort vom Kennzeichnungsaktualisierungsserver. - + Invalid reply received from the tag update server. Ungültige Antwort vom Kennzeichungsaktualisierungsserver. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8059,17 +10134,33 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabAccount - + + + My Reports + + + + Add to Buddy List Zur Freundesliste hinzufügen - + Add to Ignore List Zur Liste ignorierter Benutzer hinzufügen - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account Benutzerkonto @@ -8190,118 +10281,118 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabArchidekt - - - + + + Desc. Beschreibung. - - + + AND UND - - + + Require ALL selected colors Benötigt ALLE ausgewählten Farben - - + + Deck name... Deckname... - - + + Owner... Besitzer... - - + + Packages Pakete - - + + Advanced Filters Erweiterte Filter - + Bracket: Kategorie: - - + + Any Beliebig - - + + Contains card... Enthält Karte... - - + + Commander... Kommandant... - - + + Tag... Etikett... - - + + Deck Size Deckgröße - + Cards: Karten: - - + + Asc. Aufsteigend. - + Sort by: Sortieren nach: - + Filter by: Filtern nach: - + Display Settings Anzeigeeinstellungen - - + + Search Suche - - + + Formats Formate @@ -8314,42 +10405,42 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8362,52 +10453,52 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabDeckEditor - + Card Info Karteninformationen - + Deck Deck - + Filters Filter - + &View Ansicht - + Card Database Kartendatenbank - + Printing Auflage - + Visible Sichtbar - + Floating Schwebend - + Reset layout Darstellung zurücksetzen - + Deck: %1 Deck: %1 @@ -8415,49 +10506,49 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabDeckEditorVisual - + Visual Deck: %1 Visuelles Deck: %1 - + &Visual Deck Editor &Visueller Deckeditor - - + + Card Info Karteninformation - - + + Deck Deck - + &View &Ansehen - + Printing Auflage - + Visible Sichtbar - + Floating Schwebend - + Reset layout Anordnung zurücksetzen @@ -8465,22 +10556,22 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabDeckEditorVisualTabWidget - + Visual Deck View Visuelle Deckansicht - + Visual Database Display Visuelle Datenbankanzeige - + Deck Analytics Deckanalysen - + Sample Hand Probehand @@ -8488,139 +10579,237 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabDeckStorage - + Local file system Lokales Dateisystem - + Server deck storage Deckablage auf dem Server - - + + Open in deck editor Im Deckeditor öffnen - + Rename deck or folder Deck oder Ordner umbenennen - + Upload deck Deck hochladen - + Download deck Deck herunterladen - - - - + + + + New folder Neuer Ordner - - + + Delete Löschen - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Deckordner öffnen - + Rename local folder Lokalen Ordner umbenennen - + Rename local file Lokale Datei umbenennen - + New name: Neuer Name: - - - - + + + + + + + Error Fehler - + Rename failed Umbenennen fehlgeschlagen - - + + + Invalid deck file Ungültige Deckdatei - + Enter deck name Decknamen eingeben - + This decklist does not have a name. Please enter a name: Diese Deckliste hat keinen Namen. Bitte geben Sie einen Namen ein: - + Unnamed deck Unbenanntes Deck - + Failed to upload deck to server Hochladen des Decks zum Server fehlgeschlagen - + Delete local file Lokale Datei löschen - + Are you sure you want to delete the selected files? Sind sie sicher, dass die ausgewählten Dateien gelöscht werden sollen? - + Delete remote decks Löschen der online gespeicherten Decks - + Are you sure you want to delete the selected decks? Sind sie sicher, dass sie die ausgewählten Decks löschen wollen? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Name für den neuen Ordner: - + Deck Storage Deckablage @@ -8628,20 +10817,271 @@ Bitte geben Sie einen Namen ein: TabDeckStorageVisual - + Visual Deck Storage Visuelle Deckablage - + Error Fehler - + Could not open deck at %1 Deck an Stelle %1 konnte nicht geöffnet werden + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8654,27 +11094,27 @@ Bitte geben Sie einen Namen ein: TabEdhRecMain - + &Cards &Karten - + Top Commanders Beste Commander - + Tags Tags - + Search for a card ... Nach Karte suchen... - + Search Suche @@ -8687,191 +11127,207 @@ Bitte geben Sie einen Namen ein: TabGame - - - + + + Replay Wiederholung - - + + Game Spiel - - + + Player List Spielerliste - - + + + Invite + + + + + Card Info Karteninformationen - - + + Messages Nachrichten - - + + Replay Timeline Zeitleiste der Aufzeichnung - + &Phases &Phasen - + &Game Spi&el - + Next &phase Nächste &Phase - + Next phase with &action Nächste Phase mit &Aktion - + Next &turn Nächster &Zug - + Reverse turn order Kehre Zugreihenfolge um - + &Remove all local arrows &Lokale Pfeile entfernen - + Rotate View Cl&ockwise Ansicht im Uhrzeigesinn drehen - + Rotate View Co&unterclockwise Ansicht gegen den Uhrzeigersinn drehen - + Game &information &Spielinformationen - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede Aufgabe zurückziehen - - - + + + &Concede &Aufgeben - + &Leave game Spiel ver&lassen - + C&lose replay Wiederholung sch&ließen - + &Focus Chat Chat &fokussieren - + &Say: &Sagen: - + Selected cards Ausgewählte Karten - + &View Ansicht - + Visible Sichtbar - + Floating Schwebend - + Reset layout Darstellung zurücksetzen - + Concede Aufgeben - + Are you sure you want to concede this game? Sind Sie sicher, dass Sie das Spiel aufgeben möchten? - + Unconcede Doch nicht aufgeben - + You have already conceded. Do you want to return to this game? Sie haben bereits aufgegeben. Möchten Sie zu diesem Spiel zurückkehren? - + Leave game Spiel verlassen - + Are you sure you want to leave this game? Sind Sie sicher, dass Sie das Spiel verlassen möchten? - + A player has joined game #%1 Ein Spieler ist Spiel #%1 beigetreten - + %1 has joined the game %1 ist dem Spiel beigetreten - + You have been kicked out of the game. Sie wurden aus dem Spiel geworfen. @@ -8887,158 +11343,158 @@ Bitte geben Sie einen Namen ein: TabLog - + Logs Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Zeit;SenderName;SenderIP;Nachricht;ZielID;ZielName - + Room Logs Raumlogs - + Game Logs Spielelogs - + Chat Logs Chatlogs - - + + Error Fehler - + You must select at least one filter. Sie müssen mindestens einen Filter auswählen. - + You have to select a valid number of days to locate. Sie müssen eine gültige Anzahl an zu lokalisierenden Tagen auswählen. - + Username: Benutzername: - + IP Address: IP-Adresse: - + Game Name: Spielname: - + GameID: Spiel-ID: - + Message: Nachricht: - + Main Room Hauptraum - + Game Room Spielraum - + Private Chat Privater Chat - + Past X Days: Die letzten X Tage: - + Today Heute - + Last Hour Letzte Stunde - + Maximum Results: Maximale Ergebnisse: - + At least one filter is required. The more information you put in, the more specific your results will be. Mindestens ein Filter ist notwendig. Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. - + Get User Logs Benutzerlogs anzeigen - + Clear Filters Filter entfernen - + Filters Filter - + Log Locations Logstandorte - + Date Range Datumsbereich - + Maximum Results Maximale Ergebnisse - - + + Message History Nachrichtenverlauf - + Failed to collect message history information. Das Sammeln von Nachrichtenverlaufsinformationen ist fehlgeschlagen. - + There are no messages for the selected filters. Keine Nachrichten für die gewählten Filter. @@ -9046,219 +11502,559 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. TabMessage - + Private &chat Privater &Chat - + &Leave Ver&lassen - + %1 - Private chat %1 - privater Chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Dieser Benutzer ignoriert dich. Er kann deine Nachrichten im Hauptchat nicht sehen und du kannst seinen Spielen nicht beitreten. - + Private message from Private Nachricht von - + %1 has left the server. %1 hat den Server verlassen. - + %1 has joined the server. %1 hat den Server betreten. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Lokales Dateisystem - + Server replay storage Wiederholungsablage auf dem Server - - + + Watch replay Wiederholung abspielen - + Rename Umbenennen - - + + New folder Neuer Ordner - - + + Delete Löschen - + Open replays folder Wiederholungsorder öffnen - + Download replay Wiederholung herunterladen - + Toggle expiration lock automatische Löschung umschalten - + Get replay share code Wiederholungscode zum Teilen erhalten - - + + Look up replay by share code Wiederholung durch einen Code zum Teilen nachschauen - + Rename local folder Umbenennen des lokalen Ordners - + Rename local file Umbenennen der lokalen Datei - + New name: Neuer Name: - + Error Fehler - + Rename failed Umbenennen fehlgeschlagen - + Name of new folder: Name des neuen Ordners: - + Delete local file Lokale Datei löschen - + Are you sure you want to delete the selected files? Sind sie sicher, dass die ausgewählten Dateien gelöscht werden sollen? - + Are you sure you want to delete the selected replays? Sind sie sicher, dass die ausgewählten Wiederholungen gelöscht werden sollen? - + Failed to get code Erhalten des Codes fehlgeschlagen - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Entweder unterstützt dieser Server das Teilen von Wiederholungen nicht, oder erlaubt dir das Teilen von Wiederholungen nicht. - - - + + + Failed Fehlgeschlagen - + Could not get replay code Wiederholungs-Code konnte nicht erhalten werden - + Replay Share Code Wiederholungscode zum Teilen - + Others can use this code to add the replay to their list of remote replays: %1 Andere können diesen Code benutzen um diese Wiederholung zu ihrer Liste von Fremd-Wiederholungen hinzuzufügen: %1 - + Copy to clipboard In die Zwischenablage kopieren - + Replay share code Wiederholungscode zum Teilen - + Replay code found Wiederholungscode gefunden - + Replay was added, or you already had access to it. Wiederholung wurde hinzugefügt, oder du hattest bereits Zugang dazu. - + Replay code not found Wiederholungscode nicht gefunden - + Failed to submit code Einreichen des Codes fehlgeschlagen - + Unexpected error Unerwarteter Fehler - + Delete remote replay Wiederholung vom Server löschen @@ -9268,65 +12064,478 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. Aufgezeichnete Spiele + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Sagen: - + Chat Chat - + &Room &Raum - + &Leave room Raum ver&lassen - + &Clear chat Chat leeren - + Chat Settings... Chat Einstellungen - + mentioned you. hat dich erwähnt. - + Click to view Zum Anzeigen klicken. - + You are flooding the chat. Please wait a couple of seconds. Sie überfluten den Chatraum. Bitte warten Sie ein paar Sekunden. @@ -9334,35 +12543,35 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. TabServer - + Server Server - - - - + + + + Error Fehler - + Failed to join the server room: it doesn't exist on the server. Serverraumbeitritt fehlgeschlagen: der Raum existiert auf dem Server nicht. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Der Server denkt Sie seien im Serverraum, allerdings kann Ihr Client diesen nicht anzeigen. Versuchen Sie, Ihren Client neu zu starten. - + You do not have the required permission to join this server room. Sie haben nicht die erforderlichen Berechtigungen, um diesem Serverraum beizutreten. - + Failed to join the server room due to an unknown error: %1. Beitritt des Serverraums aufgrund eines unbekannten Fehlers fehlgeschlagen: %1. @@ -9370,97 +12579,173 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. TabSupervisor - + Deck Editor Deckeditor - + Visual Deck Editor Visueller Deckeditor - + EDHRec EDHRec - + Archidekt Archidekt - + Home Zuhause - + &Visual Deck Storage &Visuelle Deckablage - + Visual Database Display Visuelle Datenbankanzeige - + Server Server - + Account Benutzerkonto - + Deck Storage Deckablage - + Game Replays Aufgezeichnete Spiele - + Administration Administration - + Logs Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Sind Sie sicher? - + There are still open games. Are you sure you want to quit? Es sind noch Spiele offen. Wollen Sie das Programm trotzdem beenden? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Zum Anzeigen klicken - + Your buddy %1 has signed on! Dein Freund %1 hat sich eingeloggt! - + Unknown Event Unbekanntes Ereignis - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9471,39 +12756,39 @@ Dies könnte bedeuten, dass eine neue Version von Cockatrice verfügbar ist oder Um Ihren Client zu aktualisieren, navigieren Sie zu Hilfe -> Auf Aktualisierungen prüfen. - + Idle Timeout Inaktivitätszeitüberschreitung - + You are about to be logged out due to inactivity. Sie werden wegen Inaktivität abgemeldet. - + Promotion Beförderung - + You have been promoted. Please log out and back in for changes to take effect. Sie wurden befördert. Bitte loggen Sie sich aus und wieder ein, damit die Änderungen in Kraft treten. - + Warned Gewarnt - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Sie haben eine Warnung erhalten aufgrund von %1. Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie eingeleitet. Sollten Sie Fragen haben, stellen Sie diese bitte über eine private Nachricht an einen Moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) Sie haben die folgende Nachricht vom Server erhalten. @@ -9523,6 +12808,34 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Visuelle Datenbankanzeige + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9537,6 +12850,65 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Deckanalyse fehlgeschlagen. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9608,57 +12980,52 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9666,216 +13033,266 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie UserContextMenu - + User &details Benutzer&details - + Private &chat Privater &Chat - + Show this user's &games Spiele dieses &Benutzers anzeigen - + + View this user's &public decks + + + + Add to &buddy list Zur &Freundesliste hinzufügen - + Remove from &buddy list Von &Freundesliste entfernen - + Add to &ignore list &Ignorieren - + Remove from &ignore list Nicht mehr &ignorieren - + Kick from &game Aus dem &Spiel werfen - + + Report user + + + + Warn user Nutzer warnen - + View user's war&n history Verwar&nungsverlauf des Nutzers ansehen - + Ban from &server Vom &Server bannen - + View user's &ban history &Bannverlauf des Nutzers ansehen - + &Promote user to moderator Nutzer zum Moderator befördern - + Dem&ote user from moderator Moderator degradieren - + Promote user to &judge Nutzer zum Richter befördern - + Demote user from judge Richter degradieren - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes Administratornotizen ansehen - - + Investigate user + + + + + + Error Fehler - + This user does not exist. Dieser Benutzer existiert nicht. - + You are being ignored by %1 and can't see their games. Du wirst ignoriert von %1 und kannst ihre Spiele nicht sehen. - + Could not get %1's games. Konnte Spiele von %1 nicht erhalten. - + %1's games %1s Spiele - - - + + + Ban History Bannverlauf - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Bannzeit;Moderator;Banndauer;Banngrund;Sichtbarer Grund - + User has never been banned. Nutzer wurde bisher nie gebannt. - + Failed to collect ban information. Sammeln von Informationen über Bans fehlgeschlagen. - - - + + + Warning History Warnungsverlauf - + Warning Time;Moderator;User Name;Reason Warnungszeit;Moderator;Nutzername;Grund - + User has never been warned. Nutzer wurde bisher nie gewarnt. - + Failed to collect warning information. Sammeln von Informationen über Verwarnungen fehlgeschlagen. - + Failed to get admin notes. Administratornotizen konnten nicht abgerufen werden. - - + + Success Erfolgreich - + Successfully promoted user. Nutzer erfolgreich befördert. - + Successfully demoted user. Nutzer erfolgreich degradiert. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Fehlgeschlagen - + Failed to promote user. Beförderung des Nutzers fehlgeschlagen. - + Failed to demote user. Degradierung des Nutzers fehlgeschlagen. - + Copy hash to clipboard Kopiere Hash in die Zwischenablage - + Remove this user's messages Nachrichten dieses Nutzers entfernen @@ -9913,61 +13330,61 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Administrator - + Moderator Moderator - + Registered user Registrierter Benutzer - - + + Unregistered user Unregistrierter Benutzer - + Judge Richter - + Unknown Unbekannt - + The entered password does not match your account. Das eingegebene Passwort stimmt nicht mit dem Konto überein. - - - - + + + + Information Information - + User information updated. Benutzerinformationen aktualisiert. - - - - - - + + + + + - - - + + + + Error Fehler @@ -9991,283 +13408,292 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n Jahre,%n Jahre, - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Tage %20%10%n Tage %20 - + Enter Password Passwort eingeben - + Password verification is required in order to change your email address Passwortverifikation benötigt, um die E-Mail-Adresse zu ändern - - - + + + An error occurred while trying to update your user information. Ein Fehler ist während der Aktualisierung Ihrer Benutzerinformationen aufgetreten. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Dieser Server erlaubt Ihnen nicht, Ihre Benutzerinformationen zu aktualisieren. - + Password changed. Passwort geändert. - + This server does not permit you to change your password. Dieser Server erlaubt Ihnen nicht, Ihr Passwort zu ändern. - + The new password is too short. Das neue Passwort ist zu kurz. - + The old password is incorrect. Altes Passwort inkorrekt. - + Avatar updated. Avatar gewechselt. - + This server does not permit you to update your avatar. Dieser Server erlaubt Ihnen nicht, Ihren Avatar zu ändern. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Dieser Server erlaubt Ihnen nicht, Ihre Benutzerinformationen zu aktualisieren. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10275,223 +13701,344 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie UserInterfaceSettingsPage - + General interface settings Allgemeine Bedienung - + &Double-click cards to play them (instead of single-click) Karten durch &Doppelklick ausspielen (statt Einzelklick) - + &Clicking plays all selected cards (instead of just the clicked card) &Anklicken spielt alle ausgewählten Karten aus (anstatt nur der angeklickten Karte) - + &Play all nonlands onto the stack (not the battlefield) by default Alle Nichtländer standardmäßig über den Stapel spielen (anstatt direkt auf das Spielfeld) - + Do not delete &arrows inside of subphases Keine &Pfeile löschen in Unterphasen - + Close card view window when last card is removed Schließe Kartenanzeigefenster, wenn die letzte Karte entfernt wird - + Auto focus search bar when card view window is opened Sucheingabe automatisch in den Fokus nehmen, wenn das Kartenansichtsfenster geöffnet wird - + Annotate card text on tokens Kartentext auf Spielsteinen anzeigen - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Benutze Abreißmenüs, erlaubt Kontextmenüs auf dem Bildschirm zu verbleiben - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Benachrichtigungseinstellungen - + Enable notifications in taskbar Benachrichtigungen in der Taskleiste aktivieren - + Notify in the taskbar for game events while you are spectating Benachrichtigungen für Spielereignisse auch beim Zuschauen anderer Spiele in der Taskbar anzeigen - + Notify in the taskbar when users in your buddy list connect Benachrichtige in der Taskleiste wenn sich Benutzer aus der Freundesliste anmelden - + Animation settings Animationseinstellungen - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation Animiertes &Tappen/Enttappen - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings Deckeditor/-ablage Einstellungen - + Open deck in new tab by default Decks in neuem Tab öffnen als Standard - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby Visuelle Deckablage in der Spielelobby verwenden - + Use selection animation for Visual Deck Storage Auswahlanimation für Visuellen Deckspeicher verwenden - + When adding a tag in the visual deck storage to a .txt deck: Wenn ein Tag im Visuellen Deckspeicher zu einem .txt Deck hinzugefügt wird: - - do nothing - tue nichts + + Do nothing + - - ask to convert to .cod - frage, ob zu .cod konvertiert werden soll + + Ask to convert to .cod + - - always convert to .cod - immer nach .cod konvertieren + + Always convert to .cod + - + Default deck editor type Standarddeckeditortyp - + Classic Deck Editor Klassischer Deckeditor - + Visual Deck Editor Visueller Deckeditor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Wiederholungsoptionen - + Buffer time for backwards skip via shortcut: Pufferzeit für Rückwärtsüberspringen durch Tastenkürzel + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 Mit dem Server verbundene Nutzer: %1 - + Users in this room: %1 Benutzer in diesem Raum: %1 - + Buddies online: %1 / %2 Freunde online: %1 / %2 - + Ignored users online: %1 / %2 Ignorierte Benutzer online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters Alle Kartenzähler erhöhen - + &Untap all permanents &Enttappe alle bleibenden Karten - + R&oll die... &Würfeln... - + Flip coin - + &Create token... &Spielstein erzeugen... - + C&reate another token Einen weiteren Spielstein erzeugen - + Cr&eate predefined token &Vordefinierten Spielstein erzeugen @@ -10528,22 +14075,22 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... Suchfilter.... - + Save Filter Filter speichern - + Save all currently applied filters to a file Alle derzeit aktiven Filter in einer Datei speichern - + Enter filename... Dateinamen eingeben... @@ -10551,72 +14098,72 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayFilterToolbarWidget - + Sort by Sortieren nach - + Filter by Filtern nach - + Save and load filters Filter speichern und laden - + Filter by exact card name Nach exaktem Kartennamen filtern - + Filter by card main-type Nach Kartenhaupttyp filtern - + Filter by card sub-type Nach Kartenuntertyp filtern - + Filter by set Nach Set filtern - + Filter by format legality Nach Formatslegalität filtern - + Save/Load Speichern/Laden - + Name Name - + Main Type Haupttyp - + Sub Type Untertyp - + Sets Editionen - + Formats Formate @@ -10718,7 +14265,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filtern nach aktuellsten Sets @@ -10726,19 +14273,19 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplaySetFilterWidget - + Search sets... Sets durchsuchen... - - + + Mode: Exact Match Modus: Exakte Übereinstimmung - - + + Mode: Includes Modus: Enthält @@ -10784,28 +14331,28 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Suche nach Kartennamen (oder Suchausdrücken) - - + + Visual Visuell - + Loading database ... Datenbank wird geladen... - + Clear all filters Alle Filter entfernen - + Table Tisch @@ -10813,43 +14360,43 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckDisplayOptionsWidget - + Group by: Gruppieren nach: - + Change how cards are divided into categories/groups. Wie Karten innerhalb ihrer Kategorien/Gruppierungen sortiert werden ändern - + Sort by: Sortieren nach: - + Click and drag to change the sort order within the groups Klicken und ziehen, um die Sortierungsreihenfolge innerhalb von Gruppen zu ändern - + Configure how cards are sorted within their groups Wie Karten innerhalb ihrer Gruppierung sortiert werden einstellen - - + + Toggle Layout: Overlap Umschalten des Layouts: Überlappung - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) Wie Karten innerhalb von Zonen angezeigt werden ändern ( z.B. überlappend oder vollständig sichtbar) - + Toggle Layout: Flat Umschalten des Layouts: Flach @@ -10865,12 +14412,12 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckEditorSampleHandWidget - + Draw a new sample hand Eine neue Probehand ziehen - + Sample hand size Probehandgröße @@ -10878,17 +14425,17 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckEditorWidget - + Type a card name here for suggestions from the database... Hier einen Kartennahmen eingeben für Vorschläge aus der Datenbank... - + Quick search and add card Schnellsuche und Karte hinzufügen - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Nach bester Entsprechung in der Datenbank suchen (mit automatischen Vorschlägen) und die bevorzugte Auflage beim Betätigen der Eingabetaste dem Deck hinzufügen @@ -10896,7 +14443,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckStorageFolderDisplayWidget - + Deck Storage Deckablage @@ -10904,52 +14451,57 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckStorageQuickSettingsWidget - + Show Folders Ordner anzeigen - + Show Tag Filter Tagfilter anzeigen - + Show Color Identity Farbidentität anzeigen - + Show Tags On Deck Previews Tags auf Deckvorschauen anzeigen - + + Show Upload Time + + + + Show Banner Card Selection Option Bannerkartenauswahloption anzeigen - + Draw unused Color Identities Ungenutzte Farbidentitäten ziehen - + Unused Color Identities Opacity Deckkraft ungenutzter Farbidentitäten - + Deck tooltip: Decktooltip: - + None Keine - + Filepath Dateipfad @@ -10957,7 +14509,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckStorageSearchWidget - + Search by filename (or search expression) Suche nach Dateinamen (oder Suchausdruck) @@ -10965,22 +14517,22 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) Alphabetisch sortieren (Deckname) - + Sort Alphabetically (Filename) Alphabetisch sortieren (Dateiname) - + Sort by Last Modified Nach Änderungsdatum sortieren - + Sort by Last Loaded Nach letztem Laden sortieren @@ -10988,17 +14540,22 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckStorageWidget - + Loading database ... Datenbank wird geladen ... - + Refresh loaded files Geladene Dateien aktualisieren - + + Select decks to share + + + + Visual Deck Storage Settings Einstellungen des Visuellen Deckeditors @@ -11006,177 +14563,202 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie WarningDialog - + Which warning would you like to send? Welche Warnung möchten Sie versenden? - + Redact all messages from this user in all rooms Entferne alle Nachrichten dieses Nutzers in allen Räumen - + &OK &OK - + &Cancel Abbre&chen - + Warn user for misconduct Nutzer aufgrund von Fehlverhalten warnen - - + + Error Fehler - + User name to send a warning to can not be blank, please specify a user to warn. Der zu warnende Nutzername kann nicht leer gelassen werden, bitte geben Sie einen Nutzernamen an. - + Warning to use can not be blank, please select a valid warning to send. Warnungsart kann nicht leer gelassen werden, bitte wählen Sie die zu sendende Warnungsart. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Ausgewählte Edition an die Spitze verschieben - + Move selected set up nach oben verschieben - + Move selected set down nach unten verschieben - + Move selected set to the bottom Ausgewählte Edition ans Ende verschieben - - Search by set name, code, or type - Suche nach Setname, Code oder Typ - - - + Default order Standardreihenfolge - + Restore original art priority order Stelle die ursprüngliche Reihenfolge für Bilderprioritäten wieder her - + Enable all sets Alle Editionen aktivieren - + Disable all sets Alle Editionen deaktivieren - + Enable selected set(s) Ausgewählte Edition(en) aktivieren - + Disable selected set(s) Ausgewählte Edition(en) deaktivieren - + Deck Editor Deckeditor - + Use CTRL+A to select all sets in the view. Benutzen Sie Strg+A, um alle Sets in der Ansicht auszuwählen. - + Only cards in enabled sets will appear in the card list of the deck editor. Nur Karten aus aktivierten Sets werden in der Kartenliste des Deckeditors angezeigt. - + Image priority is decided in the following order: Bildpriorität wird auf folgende Reihenfolge festgelegt: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki zuerst der BENUTZERDEFINIERTE Ordner (%1), dann die aktivierten Sets aus diesem Dialog (von oben nach unten) - + Include cards rebalanced for Alchemy [requires restart] Karten, die für Alchemy augeglichen wurden, einschließen [erfordert Neustart] - + Card Art Kartenzeichnung - + + Search by set name, code, type, or release date + + + + How to use custom card art Wie benutzerdefinierte Kartenbilder verwendet werden - + Hints Tipps - + Note Notiz - + Sorting by column allows you to find a set while not changing set priority. Nach einer Spalte sortieren erlaubt, eine Edition zu finden, ohne die Editionspriorität zu verändern. - + To enable ordering again, click the column header until this message disappears. Um die Sortierung erneut zu aktivieren, klicken Sie auf den Spaltenkopf bis diese Nachricht verschwindet. - + Use the current sorting as the set priority instead Benutze die momentane Sortierung als Editionspriorität. - + Sorts the set priority using the same column Sortiert die Editionspriorität mit der selben Spalte - + Manage sets Editionen verwalten @@ -11184,93 +14766,85 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie ZoneViewWidget - + Search by card name (or search expressions) Suche nach Kartennamen (oder Suchausdrücken) - + Ungrouped Ungruppiert - + Group by Type Gruppieren nach Kartentyp - + Group by Mana Value Gruppieren nach Manabetrag - + Group by Color Gruppieren nach Farbe - + Unsorted Unsortiert - + Sort by Name Sortieren nach Name - + Sort by Type Sortieren nach Kartentyp - + Sort by Mana Cost Sortieren nach Manakosten - + Sort by Colors Sortieren nach Farben - + Sort by P/T Sortieren nach P/T - + Sort by Set Sortieren nach Auflage - + shuffle when closing beim Schließen mischen - + pile view Stapelansicht - - i18n - - - English - Deutsch (German) - - main - + Connect on startup Verbinde beim Start - + Debug to file Debugge in Datei @@ -12054,6 +15628,16 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Graveyard Friedhof + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_de.ts b/oracle/translations/oracle_de.ts index 196315a36..faf08d5ce 100644 --- a/oracle/translations/oracle_de.ts +++ b/oracle/translations/oracle_de.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Einführung - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Dieser Assistent wird eine Liste aller Editionen, Karten und Spielsteine importieren, die von Cockatrice genutzt werden. - + Interface language: Interfacesprache: - + Version: Version: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Quellenauswahl - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Bitte geben Sie eine kompatible Quelle für die Liste der Editionen und Karten an. Sie können eine URL-Adresse zum Herunterladen angeben oder eine existierende Datei von Ihrem Computer verwenden. - + Download URL: Download URL: - + Local file: Lokale Datei: - + Restore default URL Standard-URL wiederherstellen - + Choose file... Datei auswählen... - + Load sets file Editionsdatei wird geladen - + Sets file (%1) Sets JSON file (%1) Sets Datei (%1) - - - - - - - + + + + + Error Fehler - + The provided URL is not valid. Die eingegebene URL ist nicht gültig. - - Downloading (0MB) - Herunterladen (0MB) - - - + Please choose a file. Bitte wähle eine Datei aus. - + Cannot open file '%1'. Datei '%1' kann nicht geöffnet werden. - - Downloading (%1MB) - Herunterladen (%1MB) - - - + Network error: %1. Netzwerkfehler: %1. - + Parsing file Datei wird verarbeitet - + Xz extraction failed. Fehler beim Extrahieren der xz Datei. - + Sorry, this version of Oracle does not support xz compressed files. Es tut uns Leid, diese Version von Oracle unterstützt keine xz komprimierten Dateien. - + Failed to open Zip archive: %1. Fehler beim Öffnen des Zip Archivs: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Fehler beim Extrahieren: Das Zip Archiv enthält mehr als eine Datei. - + Zip extraction failed: %1. Fehler beim Extrahieren: %1. - + Sorry, this version of Oracle does not support zipped files. Es tut uns Leid, diese Version von Oracle unterstützt keine Zip Archive. - + Failed to interpret downloaded data. Interpretation der heruntergeladenen Daten fehlgeschlagen. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Möchten Sie stattdessen die unkomprimierte Datei herunterladen? - + + The file was retrieved successfully, but it does not contain any sets data. Die Datei wurde erfolgreich abgerufen, sie enthält aber keine Editionsdaten. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Speichere Spoilerdatenbank - + XML; spoiler database (*.xml) XML; Spoilerdatenbank (*.xml) - + spoiler Spoiler - + Spoilers import Spoilerimport - + Please specify a compatible source for spoiler data. Bitte geben Sie eine kompatible Quelle für Spoilerdaten an. - + Download URL: Download URL: - + Local file: Lokale Datei: - + Restore default URL Standard-URL wiederherstellen - + Choose file... Datei auswählen... - + The spoiler database will be saved at the following location: Die Spoilerdatenbank wird in folgendem Pfad gespeichert: - + Save to a custom path (not recommended) Speichere in benutzerdefiniertem Pfad (nicht empfohlen) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Speichere Spielsteindatenbank - + XML; token database (*.xml) XML; Spielsteindatenbank (*.xml) - + tokens Spielsteine - + Tokens import Spielsteinimport - + Please specify a compatible source for token data. Bitte geben Sie eine kompatible Quelle für Spielsteindaten an. - + Download URL: Download URL: - + Local file: Lokale Datei: - + Restore default URL Standard-URL wiederherstellen - + Choose file... Datei auswählen... - + The token database will be saved at the following location: Die Spielsteindatenbank wird in folgendem Pfad gespeichert: - + Save to a custom path (not recommended) Speichere in benutzerdefiniertem Pfad (nicht empfohlen) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Platzhalter Edition mit Spielsteinen @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle Importer @@ -292,22 +306,22 @@ OutroPage - + Finished Fertig - + The wizard has finished. Der Wizard ist fertig. - + You can now start using Cockatrice with the newly updated cards. Sie können nun Cockatrice mit den aktuellen Karten verwenden. - + If the card databases don't reload automatically, restart the Cockatrice client. Falls die Datenbanken nicht automatisch neu geladen werden, starten Sie bitte Cockatrice neu. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Fehler - + + No set has been imported. Es wurden keine Editionen importiert. - + Sets imported Editionen wurden importiert - - A cockatrice database file of %1 MB has been downloaded. - Eine Cockatrice-Datenbankdatei der Größe %1 MB wurde heruntergeladen. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: Die folgenden Sets wurden gefunden: - + Press "Save" to store the imported cards in the Cockatrice database. Drücken Sie "Speichern", um die importierten Karten in der Datenbank zu speichern. - + The card database will be saved at the following location: Die Kartendatenbank wird in folgendem Pfad gespeichert: - + Save to a custom path (not recommended) Speichere in benutzerdefiniertem Pfad (nicht empfohlen) - + &Save &Speichern - + + Import finished: %1 cards. Importieren abgeschlossen: %1 Karten. - + + %1: %2 cards imported %1: %2 Karten importiert. - + Save card database Kartendatenbank speichern - + XML; card database (*.xml) XML; Kartendatenbank (*.xml) - + The file could not be saved to %1 Die Datei konnte nicht gespeichert werden: %1 @@ -415,8 +432,9 @@ - Downloading (0MB) - Herunterladen (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -588,7 +606,7 @@ i18n - + English Deutsch (German) @@ -596,12 +614,12 @@ main - + Only run in spoiler mode Nur im Spoiler Modus ausführen - + Run in no-confirm background mode Ausführen im Hintergrund-Modus ohne Bestätigung From 74ae39f0d42dd9b0dd091cef3c952f19e3d5a9b9 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:35:35 +0200 Subject: [PATCH 058/247] Updates for project Cockatrice and language et (#7550) * Translate oracle_en@source.ts in et [Manual Sync] 80% of minimum 5% translated source file: 'oracle_en@source.ts' on 'et'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in et [Manual Sync] 6% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'et'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_et.ts | 10547 ++++++++++++++++----- oracle/translations/oracle_et.ts | 204 +- 2 files changed, 8110 insertions(+), 2641 deletions(-) diff --git a/cockatrice/translations/cockatrice_et.ts b/cockatrice/translations/cockatrice_et.ts index 2df3cbdac..7343c2336 100644 --- a/cockatrice/translations/cockatrice_et.ts +++ b/cockatrice/translations/cockatrice_et.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Komplektide loendaja... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': @@ -23,12 +23,12 @@ AbstractDlgDeckTextEdit - + &Refresh - + Parse Set Name and Number (if available) @@ -36,79 +36,130 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + + AccountSetupPage - - No deck was selected to be exported. + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + + + AddAnalyticsPanelDialog + + + Add Analytics Panel AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -116,202 +167,339 @@ Please check that the directory is writable and try again. AllZonesCardAmountWidget - + Mainboard - + Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - - Enabling this feature will disable the use of the Printing Selector. - -You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. - -You will have to use the Set Manager, available through Card Database -> Manage Sets. - -Are you sure you would like to enable this feature? - - - - - Disabling this feature will enable the Printing Selector. - -You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. - -You can also use the Set Manager to adjust custom sort order for printings in the Printing Selector (other sort orders like alphabetical or release date are available). - -Are you sure you would like to disable this feature? - - - - - Confirm Change - - - - + Theme settings Teema valikud - + Current theme: Praegune teema: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + + Show game filter toolbar above list in room tab + + + + Card rendering Kaartide visualiseerimine - + Display card names on cards having a picture Kuva piltidega kaartidel kaardi nimi - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + + System + + + + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + + Active theme palette: + + + + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Muuda kaartide mõõtkava hiire ülejooksul - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Käe välimus - + Display hand horizontally (wastes space) Kuva käsi horisontaalselt (raiskab ruumi) - + Enable left justification Luba vasakvormistus - + Table grid layout Laua ruudustiku vormistus - + Invert vertical coordinate Põõra vertikaalne koordinaat pahupidi - + Minimum player count for multi-column layout: Väikseim mängijate kogus mitmetulbaliseks vormistuseks: - + Maximum font size for information displayed on cards: Kaartidel kujutatava teabe fondi suurim suurus: + + ArchidektApiResponseDeckDisplayWidget + + + Back to results + + + + + Open Deck in Deck Editor + + + BackgroundSources @@ -333,112 +521,112 @@ Are you sure you would like to disable this feature? BanDialog - + ban &user name blokeeri &kasutajanimi - + ban &IP address blokeeri &IP-aadress - + ban client I&D blokeeri kliendi I&D - + Ban type Blokeerimise tüüp - + &permanent ban &igavene blokeering - + &temporary ban &ajutine blokeering - + &Days: &Päeva: - + &Hours: &Tundi: - + &Minutes: &Minutit - + Duration of the ban Blokeerimise kestvus - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Palun sisestage blokeerimise põhjus. See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. - + Please enter the reason for the ban that will be visible to the banned person. Palun sisestage blokeerimise põhjus, mis on nähtav blokeeritavale. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Tühista - + Ban user from server Blokeeri kasutaja serverist - - - - + + + + Error Viga - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Peate valima, kas nimepõhise, IP-põhise või klientidpõhise blokeeringu või mingi kombinatsiooni kolmest et blokeerida. - + You must have a value in the name ban when selecting the name ban checkbox. Sul peab olema väärtus nime blokeeringus kui valid nime blokeerimise. - + You must have a value in the ip ban when selecting the ip ban checkbox. Sul peab olema väärtus IP blokeeringus kui valid IP blokeerimise. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Sul peab olema väärtus klientid blokeeringus kui valid klientid blokeerimise. @@ -446,152 +634,378 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + Banner preview + + + + + + + + Zoom %1× + + + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Nimi - + Sets Komplektid - + Mana cost Manahind - + Card type Kaardi tüüp - + P/T T/V - + Color(s) Värv(id) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Add to Zone + + + + + Create &new zone... + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter - + AND Logical conjunction operator used in card filter - + OR Logical disjunction operator used in card filter - + AND NOT Negated logical conjunction operator used in card filter - + OR NOT Negated logical disjunction operator used in card filter - + Name - + + Name (Exact) + + + + Type - + Color - + Text - + Set - + Mana Cost - + Mana Value - + Rarity - + Power - + Toughness - + Loyalty - + Format - + Main Type - + Sub Type @@ -622,22 +1036,22 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -645,17 +1059,27 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. CardInfoTextWidget - + Name: + Set: + + + + + Collector Number: + + + + Related cards: - + Unknown card: @@ -663,132 +1087,150 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - - Toggle &normal untapping + + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget - + Card Size @@ -796,142 +1238,160 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser - + Parse error at line %1 col %2: @@ -939,29 +1399,572 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. CockatriceXml4Parser - + Parse error at line %1 col %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' + + DeckAnalyticsWidget + + + Add Panel + + + + + Remove Panel + + + + + Save Layout + + + + + Load Layout + + + + + Include Sideboard + + + + + DeckEditorCardDatabaseDockWidget + + + Card Database + + + DeckEditorCardInfoDockWidget - + Card Info @@ -969,47 +1972,17 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1017,87 +1990,158 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckEditorDeckDockWidget - + + Loading Database... + + + + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + + Format: + + + + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1105,17 +2149,17 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1123,114 +2167,119 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - - - Load deck from cl&ipboard... - - - - - Edit deck in clipboard - - - - - - Annotated - - - - - - Not Annotated - - - Save deck to clipboard + Share deck... - Annotated (No set info) + Load deck from cl&ipboard... - Not Annotated (No set info) + Edit deck in clipboard + + + + + + Annotated - &Print deck... + + Not Annotated - Load deck from online service... - - - - - &Send deck to online service + Save deck to clipboard - Create decklist (decklist.org) - - - - - Create decklist (decklist.xyz) + Annotated (No set info) + Not Annotated (No set info) + + + + + &Print deck... + + + + + Load deck from online service... + + + + + &Send deck to online service + + + + + Create decklist (decklist.org) + + + + + Create decklist (decklist.xyz) + + + + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1238,7 +2287,7 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1246,194 +2295,213 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckEditorSettingsPage - - + + Update Spoilers - - + Success - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL - - + + URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - - - - + Reset Download URLs + + + DeckListHistoryManagerWidget - - On-disk cache for downloaded pictures + + Undo - - In-memory cache for pictures not currently on screen + + Redo + + + + + Undo/Redo history + + + + + Click on an entry to revert to that point in the history. + + + + + [redo] + + + + + [undo] DeckListModel - + Count - + Set - + Number Number - + Provider ID - + Card Kaart @@ -1441,12 +2509,12 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1454,17 +2522,22 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -1472,7 +2545,7 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -1480,62 +2553,62 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -1543,98 +2616,216 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed + + DeckStateManager + + + Rename deck to "%1" from "%2" + + + + + Updated comments (was %1 chars, now %2 chars) + + + + + Set banner card to %1 (%2) + + + + + Set playmat to %1 + + + + + Tags changed + + + + + Set format to %1 + + + + + Added (%1): %2 (%3) %4 + + + + + Moved to %1 1 × "%2" (%3) + + + + + Removed "%1" (all copies) + + + + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + + %1 1 × "%2" (%3) + + + + + Added + + + + + Removed + + + DeckStatsInterface @@ -1652,94 +2843,122 @@ See on mõeldud ainult moderaatoritele ja blokeeritav isik seda ei näe. DeckViewContainer - + Load deck... Lae pakk... - + Load remote deck... Lae kaugpakk... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Viga - + The selected file could not be loaded. Valitud faili ei suudetud laadida. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -1752,143 +2971,143 @@ This will kick all non-ready players from the game. - + Known Hosts Teadaolevad hostid - + Delete the currently selected saved server - + Refresh the server list with known public servers - + New Host Uus võõrustaja - + Name: Nimi: - + &Host: &Võõrustaja: - + &Port: &Port: - + Player &name: Mängija &nimi: - + P&assword: S&alasõna: - + &Save password &Salvesta salasõna - + A&uto connect Automaatne ühendamine - + Automatically connect to the most recent login when Cockatrice opens Automaatselt ühenda viimasele sisselogimisele kui Cockatrice avaneb - + If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage - + Reset Password - + Forgot password? - + &Connect - + Server Server - + Login Sisselogimine - + Server Contact - + Connect to Server - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning Ühenduse hoiatus - + You need to name your new connection profile. Peate oma uuele ühendusprofiilile valima nime. - + Connect Warning Ühendumisehoiatus - + The player name can't be empty. Mängija nimi ei saa olla tühi @@ -1981,32 +3200,37 @@ This will kick all non-ready players from the game. - + + Create game as judge + + + + Game setup options - + &Clear &Tühjenda - + Create game Loo mäng - + Game information Mängu info - + Error Viga - + Server error. Serveri viga. @@ -2014,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nimi: - + Token Märgistused - + C&olor: V&ärv: - + white valge - + blue sinine - + black must - + red punane - + green roheline - + multicolor mitmevärviline - + colorless värvitu - + &P/T: &T/V: - + &Annotation: &Märkus: - + &Destroy token when it leaves the table &Hävita märgistus kui see lahkub laualt - + Create face-down (Only hides name) - + Token data Märgistusinfo - + Show &all tokens Näita &kõik märgistused - + Show tokens from this &deck Näita märgistusi siit &pakist - + Choose token from list Vali märgistus nimekirjast - + Create token Loo märgistus @@ -2112,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2166,13 +3390,13 @@ This will kick all non-ready players from the game. DlgEditAvatar - - + + No image chosen. Pilti pole valitud - + To change your avatar, choose a new image. To remove your current avatar, confirm without choosing a new image. Et muuta avatari, vali uus pilt. @@ -2180,26 +3404,31 @@ Et eemaldada praegust avatari, kinnita ilma uue pildi valimata. + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Otsi... - + Change avatar Muuda avatar - + Open Image Ava pilt - + Image Files (*.png *.jpg *.bmp) Pildi Failid (*.png *.jpg *.bmp) - + Invalid image chosen. Vigane pilt valitud. @@ -2207,17 +3436,17 @@ Et eemaldada praegust avatari, kinnita ilma uue pildi valimata. DlgEditDeckInClipboard - + Edit deck in clipboard - + Error - + Invalid deck list. @@ -2225,38 +3454,38 @@ Et eemaldada praegust avatari, kinnita ilma uue pildi valimata. DlgEditPassword - + Old password: Vana salasõna: - + New password: Uus salasõna: - + Confirm new password: Kinnita uus salasõna: - + Change password Vaheta salasõna - - + + Error Viga - + Your password is too short. - + The new passwords don't match. Uued salasõnad ei kattu. @@ -2264,83 +3493,83 @@ Et eemaldada praegust avatari, kinnita ilma uue pildi valimata. DlgEditTokens - + &Name: &Nimi: - + C&olor: V&ärv: - + white valge - + blue sinine - + black must - + red punane - + green roheline - + multicolor mitmevärviline - + colorless värvitu - + &P/T: &T/V: - + &Annotation: &Märkus: - + Token data Märgistusandmed - - + + Add token Lisa märgistus - + Remove token Eemalda märgistus - + Edit custom tokens - + Please enter the name of the token: Sisenda märgistuse nimi: @@ -2374,12 +3603,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia Ebamääratletud - + Real name: Päris nimi: - + Edit user profile Muuda kasutaja profiili @@ -2417,112 +3646,115 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Hide 'buddies only' games - + Hide full games - - Hide games that have started + + Hide started games + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - - Hide games not created by buddy + + Hide games not created by buddies + Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: Mängu &kirjeldus: - - &Creator name: - %Looja nimi: + + &Host name: + &Creator name: + - + General - + &Game types &Mängu tüübid - + at &least: &vähemalt: - + at &most: &enamjaolt: - + Maximum player count Mängijate ülempiir - + Restrictions Piirangud - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games Filtreeri mänge @@ -2705,10 +3937,43 @@ Make sure to enable the 'Token' set in the "Manage sets" dia + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -2716,17 +3981,17 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgLoadDeckFromClipboard - + Load deck from clipboard Lae pakk lõikelaualt - + Error Viga - + Invalid deck list. Vigane pakilist: @@ -2734,43 +3999,43 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgLoadDeckFromWebsite - + Paste a link to a decklist site here to import it. (Archidekt, Deckstats, Moxfield, and TappedOut are supported.) - - - - - + + + + + Load Deck from Website - + No parser available for this deck provider. (Archidekt, Deckstats, Moxfield, and TappedOut are supported.) - + Network error: %1 - + Received empty deck data. - + Failed to parse deck data: %1 - + The provided URL is not recognized as a valid deck URL. Valid deck URLs look like this: @@ -2784,155 +4049,589 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLoadRemoteDeck - + Load deck Lae pakk + + DlgLocalGameOptions + + + Players: + + + + + General + + + + + Starting life total: + + + + + Game setup options + + + + + Remember settings + + + + + Local game options + + + + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: &Võõrustaja: - + &Port: &Port: - + Player &name: Mängija &nimi: - + P&assword: S&alasõna: - + Password (again): Salasõna (uuesti): - + Email: Email: - + Email (again): Email (uuesti): - + Country: Riik: - + Undefined Ebamääratletud - + Real name: Päris nimi: - + Register to server Registreeri serveriga - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Registreerimishoiatus - + Your password is too short. - + Your passwords do not match, please try again. Su salasõnad ei kattu, palun proovi uuesti. - + Your email addresses do not match, please try again. Su emailid ei kattu, palun proovi uuesti. - + The player name can't be empty. Mängija nimi ei saa olla tühi. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -2940,74 +4639,55 @@ Your email will be used to verify your account. DlgSelectSetForCards - + Unmodified Cards: - + Modified Cards: - + Check Sets to enable them. Drag-and-Drop to reorder them and change their priority. Cards will use the printing of the highest priority enabled set. - + Clear all set information - + Set all to preferred + + + Bulk modified printings. + + + + + Cleared all printing information. + + + + + Set all printings to preferred. + + DlgSettings - + Unknown Error loading card database Ebateatud kaardi laadimis andmebaas - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Su kaardi andmebaas on vigane. - -Cockatrice ei või korralikult töötada vigase andmebaasiga - -Sul võib vaja olla oraaklit uuesti jooksutada et uuendada kaardi andmebaasi. - -Kas sa tahaksid muuta andmebaasi asukoha seadmeid? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Su kaardi andmebaasi versioon on liiga vana. - -See võib luua probleeme kaartide informatsiooni või piltide laadimisega - -Tavaliselt seda saab parantada oraakli uuesti jooksutamisega et uuentada kaartida andmebaasi. - -Kas sa tahad muuta andmebaasi asukoha valikuid? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3016,7 +4696,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3025,7 +4705,7 @@ Would you like to change your database location setting? Kaas sa tahaks muuta andmebaasi asukoha valikuid? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3034,7 +4714,7 @@ Would you like to change your database location setting? Kas sa tahaks muuta enda andmebaasi asukoha valikuid? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3043,99 +4723,235 @@ Would you like to change your database location setting? - - - + + + Error Viga - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Tee paki kataloogi on vigane. Kas sa tahaks tagasi minna ja seada õige tee? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Tee kaartide piltidele on vigane. Kas sa tahaks tagasi minna ja seada õige tee? - + Settings Valikud - + + Search settings... + + + + + OK + + + + General Üldine - + Appearance Välimus - + User Interface Kasutajaliides - + + Storage + + + + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Jutustamine - + Sound Hääl - + Shortcuts Lühiteed + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3161,164 +4977,174 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel - + Reinstall - + Cancel Download - + Open Download Page Ava allalaadimisleht - + Check for Client Updates - - - - + + + + Error Viga - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 - + Checking for updates... Uuenduste otsimine... - + Finished checking for updates - + No Update Available - + Cockatrice is up to date! - + You are already running the latest version available in the chosen release channel. - + Current version - + Selected release channel - - + + Update Available - - + + A new version of Cockatrice is available! - - + + New version - - + + Released - - + + Changelog - + Do you want to update now? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Uuendusviga - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... Paigaldamine... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3326,21 +5152,153 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log Putukaeemalduslogi + + DrawProbabilityConfigDialog + + + Draw Probability Settings + + + + + Criteria: + + + + + Card Name + + + + + Type + + + + + Subtype + + + + + Mana Value + + + + + Exactness: + + + + + At least + + + + + Exactly + + + + + Quantity (N): + + + + + Cards drawn (M): + + + + + + cards + + + + + DrawProbabilityWidget + + + Draw Probability + + + + + Probability of drawing + + + + + Card Name + + + + + Type + + + + + Subtype + + + + + Mana Value + + + + + At least + + + + + Exactly + + + + + card(s) having drawn at least + + + + + cards + + + + + Category + + + + + Qty + + + + + Odds (%) + + + EdhrecApiResponseCardInclusionDisplayWidget @@ -3410,33 +5368,39 @@ You may have to manually download the new version. + + EdhrecCommanderApiResponseBracketNavigationWidget + + + Game Changers + + + + + EdhrecCommanderApiResponseBudgetNavigationWidget + + + Budget + + + EdhrecCommanderApiResponseNavigationWidget - + Combos - + Average Deck - - - Game Changers - - - - - Budget - - EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -3444,7 +5408,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -3452,45 +5416,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3498,226 +5505,295 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Viga - + Please join the appropriate room first. Palun ühine sobiva toaga enne. - + Wrong password. Vale salasõna. - + Spectators are not allowed in this game. Vaatlejad ei ole selles mängus lubatud. - + The game is already full. Mäng on juba täis. - + The game does not exist any more. - + This game is only open to registered users. - + This game is only open to its creator's buddies. - + You are being ignored by the creator of this game. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + + Join Game as Judge + + + + + Spectate Game as Judge + + + + + Join game - - Password: + + The game is full. Join as a spectator instead? - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. - + &Filter games - + C&lear filter - + C&reate - + &Join - + + Join as judge + + + + J&oin as spectator - + + Join as judge spectator + + + + Games shown: %1 / %2 - + Games + + GameSelectorQuickFilterToolBar + + + All types + + + + + Filter by game name... + + + + + Filter by game type/format + + + + + Hide games not created by buddies + + + + + Hide full games + + + + + Hide started games + + + GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password - + buddies only - + reg. users only - + open decklists - - + + can chat - + see hands - + can see hands - + not allowed - + Room - + Age - + Description - - Creator + + Host - + Type - + Restrictions - + Players - + Spectators @@ -3725,143 +5801,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + Eesti (Estonian) + + + + + + + + + Choose path - - Personal settings + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> - + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + + Language settings + + + + Language: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + + Version settings + + + + + Card database + + + + + Startup settings + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) - + Paths - + How to help with translations - + Decks directory: - + Filters directory: - + Replays directory: - + Pictures directory: - + Card database: - + Custom database directory: - + Token database: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -3869,47 +6077,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -3917,305 +6125,649 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - - &Sort hand + + Sort hand by... - - Take &mulligan + + Name - + + Type + + + + + Mana Value + + + + + Take &mulligan (Choose hand size) + + + + + Take mulligan (Same hand size) + + + + + Take mulligan (Hand size - 1) + + + + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - Reveal r&andom card to... + + + All players - - - &All players + + Reveal r&andom card to... + + + + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + + Browse Archidekt + + + + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + + Move top cards to graveyard face down... + + + + Move top cards to &exile... - + + Move top cards to exile face down... + + + + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + + Move bottom cards to graveyard face down... + + + + Move bottom cards to &exile... - + + Move bottom cards to exile face down... + + + + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4223,1416 +6775,1244 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - - - - - There are too many concurrent connections from your address. - - - - - Banned by moderator - - - - - Expected end time: %1 - - - - - This ban lasts indefinitely. - - - - - Scheduled server shutdown. - - - - - - Invalid username. - - - - - You have been logged out due to logging in at another location. - - - - - Connection closed - - - - - The server has terminated your connection. -Reason: %1 - - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - - - - - Scheduled server shutdown - - - - - - Success - - - - - Registration accepted. -Will now login. - - - - - Account activation accepted. -Will now login. - - - - - Number of players - - - - - Please enter the number of players. - - - - - + + Player %1 - + Load replay - + About Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error - - Server timeout - - - - - Failed Login - - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - - - - - There is already an active session using this user name. -Please close that session first and re-login. - - - - - - You are banned until %1. - - - - - - You are banned indefinitely. - - - - - This server requires user registration. Do you want to register now? - - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - - - - - Unknown login error: %1 - - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - - Your username must respect these rules: - - - - - is %1 - %2 characters long - - - - - can %1 contain lowercase characters - - - - - - - - NOT - - - - - can %1 contain uppercase characters - - - - - can %1 contain numeric characters - - - - - can contain the following punctuation: %1 - - - - - first character can %1 be a punctuation mark - - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - - Registration denied - - - - - Registration is currently disabled on this server - - - - - There is already an existing account with the same user name. - - - - - It's mandatory to specify a valid email address when registering. - - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - - - - - Registration failed for a technical problem on the server. - - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - - - - - Account activation failed - - - - - Socket error: %1 - - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - - - - - Connecting to %1... - - - - - Registering to %1 as %2... - - - - - Disconnected - - - - - Connected, logging in at %1 - - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... - + &Disconnect - + Start &local game... - + &Watch replay... - + &Full screen - + &Register to server... - + &Restore password... - + &Settings... - + &Exit - + A&ctions - + &Cockatrice - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help - + &About Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. + + + ManaBaseConfigDialog - - - - Reset Password + + Mana Base Configuration - - Your password has been reset successfully, you can now log in using the new credentials. + + Display type: - - Failed to reset user account password, please contact the server operator to reset your password. + + pie - - Activation request received, please check your email for an activation token. + + bar + + + + + combinedBar + + + + + Filter Colors (optional): + + + + + OK + + + + + Cancel ManaBaseWidget - - + Mana Base + + ManaCurveConfigDialog + + + Group By: + + + + + type + + + + + color + + + + + subtype + + + + + power + + + + + toughness + + + + + Filters (optional): + + + + + Show main bar row + + + + + Show per-category rows + + + ManaCurveWidget - - + Mana Curve + + ManaDevotionConfigDialog + + + Display type: + + + + + pie + + + + + bar + + + + + combinedBar + + + + + Filter Colors (optional): + + + ManaDevotionWidget - - + Mana Devotion + + ManaDistributionConfigDialog + + + Top display type: + + + + + pie + + + + + bar + + + + + Colors: + + + + + Show per-color rows + + + + + ManaDistributionSingleDisplayWidget + + + %1 pips (%2 cards) + + + + + %1 mana (%2 cards) + + + + + ManaDistributionWidget + + + Mana Production + Devotion + + + + + Mana Distribution Settings + + + MessageLogWidget - + from play - + from their graveyard - + from exile - + from their hand - + the top card of %1's library - + the top card of their library - + from the top of %1's library - + from the top of their library - + the bottom card of %1's library - + the bottom card of their library - + from the bottom of %1's library - + from the bottom of their library - + from %1's library - + from their library - + from sideboard - + from the stack - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card - + %1 gives %2 control over %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. - + + %1 puts %2%3 into their graveyard face down. + + + + %1 puts %2%3 into their graveyard. - + + %1 exiles %2%3 face down. + + + + %1 exiles %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. - + + %1 plays %2%3 face down. + + + + %1 plays %2%3. - + + %1 moves %2%3 to custom zone '%4' face down. + + + + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. - + The game has started. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. - + You have been kicked out of the game. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -5640,110 +8020,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message - - + + Message: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -5751,37 +8146,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + + T&able + + + + &Hand - + &Graveyard - + &Exile @@ -5794,47 +8194,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -5857,65 +8257,197 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + + Save + + + + + Delete this scheme's custom palette and revert to the theme default (or the application palette) + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Cannot save: this theme has no writable directory + + + + + Write palette-%1.toml and reload the theme + + + + + + Save failed + + + + + + Could not write %1 to: +%2 + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + + Phase - + Unknown Phase - + Untap - + Upkeep - + Draw - + First Main - + Beginning of Combat - + Declare Attackers - + Declare Blockers - + Combat Damage - + End of Combat - + Second Main - + End/Cleanup @@ -5923,253 +8455,552 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step - + Upkeep step - + Draw step - + First main phase - + Beginning of combat step - + Declare attackers step - + Declare blockers step - + Combat damage step - + End of combat step - + Second main phase - + End of turn step - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - + + + + + grave + + + + + + + + exile + + + + + PlayerDialogs + + View top cards of library - - - - - - - - - - - + + + + + + + + + Number of cards: (max. %1) - + View bottom cards of library - + Shuffle top cards of library - + Shuffle bottom cards of library - + Draw hand - + 0 and lower are in comparison to current hand size - + Draw cards - - Move top cards to grave + + Move top cards to %1 - - Move top cards to exile + + Move bottom cards to %1 - - Move bottom cards to grave - - - - - Move bottom cards to exile - - - - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters + + + PlaymatCollectionDialog - - S&ay + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + PrintingDisabledInfoWidget + + + The Printing Selector is disabled because you have currently enabled the setting to override all selected printings with personal set preferences. + +This setting means you'll only see the default printing for each card, instead of being able to select a printing, and will not see the printings other people have selected. + + + + + + + Enable printings again PrintingSelector - + Display Navigation Buttons + + + Printing Selector + + PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -6182,17 +9013,17 @@ Cockatrice will now reload the card database. PrintingSelectorCardSelectionWidget - + Previous Card in Deck - + Bulk Selection - + Next Card in Deck @@ -6200,124 +9031,140 @@ Cockatrice will now reload the card database. PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending + + PrintingSelectorPlaceholderWidget + + + Select a card to view its available printings + + + PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -6325,224 +9172,401 @@ Cockatrice will now reload the card database. QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) - + Maindeck - + Sideboard - + Tokens - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? + + + Enabling this feature will disable the use of the Printing Selector. + +You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. + +You will have to use the Set Manager, available through Card Database -> Manage Sets. + +Are you sure you would like to enable this feature? + + + + + Disabling this feature will enable the Printing Selector. + +You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. + +You can also use the Set Manager to adjust custom sort order for printings in the Printing Selector (other sort orders like alphabetical or release date are available). + +Are you sure you would like to disable this feature? + + + + + Confirm Change + + + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name - + ID - + Upload time + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -6550,70 +9574,78 @@ Cockatrice will now reload the card database. RoomSelector - + Rooms - + Joi&n - + Room - + Description - + Permissions - + Players - + Games + + SayMenu + + + S&ay + + + SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -6621,81 +9653,117 @@ Cockatrice will now reload the card database. SetsModel - + Enabled - + Set type - + Set code - + Long name - + Release date + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -6703,12 +9771,12 @@ Cockatrice will now reload the card database. ShortcutTreeView - + Action - + Shortcut @@ -6716,13 +9784,13 @@ Cockatrice will now reload the card database. ShortcutsSettings - + Your configuration file contained invalid shortcuts. Please check your shortcut settings! - + The following shortcuts have been set to default: @@ -6749,12 +9817,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -6762,27 +9830,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -6790,48 +9858,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -6839,45 +9907,204 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -6995,56 +10222,227 @@ Please check your shortcut settings! + + TabArchidekt + + + + + Desc. + + + + + + AND + + + + + + Require ALL selected colors + + + + + + Deck name... + + + + + + Owner... + + + + + + Packages + + + + + + Advanced Filters + + + + + Bracket: + + + + + + Any + + + + + + Contains card... + + + + + + Commander... + + + + + + Tag... + + + + + + Deck Size + + + + + Cards: + + + + + + Asc. + + + + + Sort by: + + + + + Filter by: + + + + + Display Settings + + + + + + Search + + + + + + Formats + + + + + Archidekt: + + + + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor - + Card Info - + Deck - + Filters - + &View - + + Card Database + + + + Printing - - - - + Visible - - - - + Floating - + Reset layout - + Deck: %1 @@ -7052,61 +10450,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - - - - + Visible - - - - + Floating - + Reset layout @@ -7114,22 +10500,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7137,138 +10523,236 @@ Please check your shortcut settings! TabDeckStorage - + Local file system - + Server deck storage - - + + Open in deck editor - + Rename deck or folder - + Upload deck - + Download deck - - - - + + + + New folder - - + + Delete - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name - + This decklist does not have a name. Please enter a name: - + Unnamed deck - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: - + Deck Storage @@ -7276,20 +10760,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -7302,32 +11037,32 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search - + EDHRec: @@ -7335,197 +11070,207 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + + Invite + + + + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases - + &Game - + Next &phase - + Next phase with &action - + Next &turn - + Reverse turn order - + &Remove all local arrows - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede - + &Leave game - + C&lose replay - + &Focus Chat - + &Say: - + Selected cards - + &View - - - - + Visible - - - - + Floating - + Reset layout - + Concede - + Are you sure you want to concede this game? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game - + Are you sure you want to leave this game? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -7533,7 +11278,7 @@ Please enter a name: TabHome - + Home @@ -7546,152 +11291,152 @@ Please enter a name: - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs - + Chat Logs - - + + Error - + You must select at least one filter. - + You have to select a valid number of days to locate. - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -7699,218 +11444,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat - + &Leave - + %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. - + %1 has joined the server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -7920,50 +12005,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - + &Say: - + Chat - + &Room - + &Leave room - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. @@ -7971,35 +12484,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server - - - - + + + + Error - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8007,92 +12520,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + + Archidekt + + + + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? - + There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8100,38 +12694,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8139,12 +12733,45 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay + + + Database Display + + Visual Database Display + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -8159,6 +12786,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -8177,42 +12863,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -8220,214 +12906,327 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Remove Banner Card + + + UserContextMenu - + User &details - + Private &chat - + Show this user's &games - - - Add to &buddy list - - - - - Remove from &buddy list - - - - - Add to &ignore list - - - - - Remove from &ignore list - - - Kick from &game + View this user's &public decks - Warn user + Add to &buddy list - View user's war&n history + Remove from &buddy list - Ban from &server + Add to &ignore list - View user's &ban history + Remove from &ignore list - &Promote user to moderator + Kick from &game - Dem&ote user from moderator + Report user - Promote user to &judge + Warn user - Demote user from judge + View user's war&n history + Ban from &server + + + + + View user's &ban history + + + + + &Promote user to moderator + + + + + Dem&ote user from moderator + + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - - - Error - - - - - This user does not exist. - - - - - You are being ignored by %1 and can't see their games. + + Investigate user + + + Error + + + + + This user does not exist. + + + + + You are being ignored by %1 and can't see their games. + + + + Could not get %1's games. - + %1's games - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - - - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -8435,48 +13234,48 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: - + Account Age: - + Edit - + Change password - + Change avatar - + Administrator - + Moderator - + Registered user - - + + Unregistered user @@ -8486,408 +13285,913 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Unknown - + The entered password does not match your account. - - - + + + + Information - + User information updated. - - - - - - - - - - + + + + + + + + + + + Error - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. - + Password changed. - + This server does not permit you to change your password. - + The new password is too short. - + The old password is incorrect. - + Avatar updated. - + This server does not permit you to update your avatar. - - An error occured while trying to updater your avatar. + + An error occured while trying to update your avatar. + + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + Invite + + + + + Invite to your game + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. UserInterfaceSettingsPage - + General interface settings - + &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + + Do not delete &arrows inside of subphases + + + + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - + + Show selection count during drag selection + + + + + Show total selection count + + + + + + Disabled + + + + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect - + Animation settings - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget - Mode: Exact Match + Exact match - - Mode: Includes + + Includes - - Mode: Include/Exclude + + Include / Exclude + Mode: Includes - - Filter mode (AND/OR/NOT conjunctions of filters) + + How selected and unselected colors are combined in the filter VisualDatabaseDisplayFilterSaveLoadWidget - + + Search filter... + + + + Save Filter - + Save all currently applied filters to a file - + Enter filename... + + VisualDatabaseDisplayFilterToolbarWidget + + + Sort by + + + + + Filter by + + + + + Save and load filters + + + + + Filter by exact card name + + + + + Filter by card main-type + + + + + Filter by card sub-type + + + + + Filter by set + + + + + Filter by format legality + + + + + Save/Load + + + + + Name + + + + + Main Type + + + + + Sub Type + + + + + Sets + + + + + Formats + + + + + VisualDatabaseDisplayFormatLegalityFilterWidget + + + Show formats with at least: + + + + + cards + + + + + Do not display formats with less than this amount of cards in the database + + + + + Filter mode (AND/OR/NOT conjunctions of filters) + + + + + Mode: Exact Match + + + + + Mode: Includes + + + VisualDatabaseDisplayMainTypeFilterWidget - + + Show main types with at least: + + + + + cards + + + + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes VisualDatabaseDisplayNameFilterWidget - - - Filter by name... - - - - - Load from Deck - - - Apply all card names in currently loaded deck as exact match name filters + Filter by name... (Exact match) - Load from Clipboard + Load from Deck + Apply all card names in currently loaded deck as exact match name filters + + + + + Load from Clipboard + + + + Apply all card names in clipboard as exact match name filters @@ -8895,7 +14199,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -8903,19 +14207,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -8923,27 +14227,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + + Show sub types with at least: + + + + + cards + + + + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -8951,50 +14265,93 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + + + Visual + + + + Loading database ... - + Clear all filters - - Save and load filters + + Table + + + + + VisualDeckDisplayOptionsWidget + + + Group by: - - Filter by exact card name + + Change how cards are divided into categories/groups. - - Filter by card sub-type + + Sort by: - - Filter by set + + Click and drag to change the sort order within the groups + + + + + Configure how cards are sorted within their groups + + + + + + Toggle Layout: Overlap + + + + + Change how cards are displayed within zones (i.e. overlapped or fully visible.) + + + + + Toggle Layout: Flat + + + + + VisualDeckEditorPlaceholderWidget + + + Add cards using the search bar or database tab to have them appear here VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -9002,46 +14359,25 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - - Click and drag to change the sort order within the groups + + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter - - - Configure how cards are sorted within their groups - - - - - - Overlap Layout - - - - - Change how cards are displayed within zones (i.e. overlapped or fully visible.) - - - - - Flat Layout - - VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -9049,47 +14385,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + + Show Color Identity + + + + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -9097,7 +14443,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -9105,22 +14451,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -9128,17 +14474,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -9146,46 +14497,71 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets @@ -9209,11 +14585,6 @@ Please refrain from engaging in this activity or further actions may be taken ag Move selected set to the bottom - - - Search by set name, code, or type - - Default order @@ -9225,98 +14596,103 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + + Search by set name, code, type, or release date + + + + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -9324,93 +14700,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing - + pile view - - i18n - - - English - Eesti Keel (Estonian) - - main - + Connect on startup - + Debug to file @@ -9418,1005 +14786,1088 @@ Please refrain from engaging in this activity or further actions may be taken ag shortcutsTab - + Main Window - - + + Deck Editor - + Game Lobby - + Card Counters - + Player Counters - + Power and Toughness - + Game Phases - + Playing Area - + Move Selected Card - + View - + Move Top Card - + Move Bottom Card - + Gameplay - + Drawing - + Chat Room - + Game Window - + Load Deck from Clipboard - - + + Replays - + Tabs - + Check for Card Updates... - + Connect... - + Disconnect - + Exit - + Full screen - + Register... - + Settings... - + Start a Local Game... - + Watch Replay... - + Analyze Deck (deckstats.net) - + Analyze Deck (tappedout.net) - + Clear All Filters - + Clear Selected Filter - + Close - + Remove Card - + Manage Sets... - + Edit Custom Tokens... - + Export Deck (decklist.org) - + Export Deck (decklist.xyz) - + Add Card - + Load Deck... - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter - + Show Status Bar - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap - + Upkeep - + Draw - + First Main Phase - + Start Combat - + Attack - + Block - + Damage - + End Combat - + Second Main Phase - + End - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - - Toggle Untap + + Toggle Skip Untapping + Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + + Play Card, Face Down + + + + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - - + + Reveal Selected Cards to All Players + + + + + Bottom of Library - - - - + + + + Exile - - - - + + + + Graveyard - - + + Report Queue + + + + + Moderation + + + + + + Hand - - + + Top of Library - - - + + Battlefield, Face Down - + Battlefield - - Sort Hand - - - - + Library - + Sideboard - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + + Graveyard (Multiple), Face Down + + + + + Exile (Multiple) - + + + Exile (Multiple), Face Down + + + + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan - + + Mulligan (Same hand size) + + + + + Mulligan (Hand size - 1) + + + + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + + Sort Hand by Name + + + + + Sort Hand by Type + + + + + Sort Hand by Mana Value + + + + + Reveal Hand to All Players + + + + + Reveal Random Card to All Players + + + + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/oracle/translations/oracle_et.ts b/oracle/translations/oracle_et.ts index 5a5e915db..1f06f14df 100644 --- a/oracle/translations/oracle_et.ts +++ b/oracle/translations/oracle_et.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Sissejuhatus - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. See võlur impordib Cockatrice’is kasutatava nimekirja komplektidest, kaartidest ja märgistustest. - + Interface language: - + Version: Versioon: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Allika valik - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Palun täpsusta ühilduv allikas komplektide ja kaartide nimekirja jaoks. Võid täpsustada URL-i aadressi, mis laaditakse alla või kasutada olemasolevat faili oma arvutist. - + Download URL: Allalaadimise URL: - + Local file: Fail arvutis: - + Restore default URL Taasta tavaURL - + Choose file... Valige fail.... - + Load sets file Lae komplektide kaust - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Viga - + The provided URL is not valid. Antud URl pole kehtiv. - - Downloading (0MB) - Allalaadimine (0MB) - - - + Please choose a file. Palun valige fail. - + Cannot open file '%1'. Ei suudeta avada '%1'. - - Downloading (%1MB) - Allalaadimine (%1MB) - - - + Network error: %1. Võrgu viga: %1. - + Parsing file Faili hankimine - + Xz extraction failed. Xzi lahtipakkimine nurjus. - + Sorry, this version of Oracle does not support xz compressed files. Vabandame, aga antud Oracle’i versioon ei toeta xz kokkupakitud faile. - + Failed to open Zip archive: %1. Zip-arhiivi avamine ebaõnnestus: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Zip-i lahtipakkimine ebaõnnestus: Zip-arhiiv sisaldab rohkem faile kui üks. - + Zip extraction failed: %1. Zipi lahtipakkimine ebaõnnestus: %1. - + Sorry, this version of Oracle does not support zipped files. Vabandame, aga antud Oracle versioon ei toeta kokkupakitud faile. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Soovid alla laadida hoopis pakkimata faili? - + + The file was retrieved successfully, but it does not contain any sets data. Fail on edukalt alla laetud, ent ei sisalda andmeid. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Salvesta spoileri andmebaas - + XML; spoiler database (*.xml) XML; spoileri andmebaas (*.xml) - + spoiler - + Spoilers import Spoilerite import - + Please specify a compatible source for spoiler data. Täpsusta spoileri andmetega ühilduv allikas. - + Download URL: Allalaadimise URL: - + Local file: - + Restore default URL Taasta tavaURL - + Choose file... - + The spoiler database will be saved at the following location: Spoileri andmebaas salvestatakse järgmisesse asukohta: - + Save to a custom path (not recommended) Salvesta enda määratud asukohta (pole soovitatav) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Salvesta märgistuste andmebaas - + XML; token database (*.xml) XML; märgistuste andmebaas (*.xml) - + tokens - + Tokens import Märgistuste importimine - + Please specify a compatible source for token data. Täpsusta märgistuste andmetega ühilduv allikas. - + Download URL: Allalaadimise URL: - + Local file: - + Restore default URL Taasta tavaURL - + Choose file... - + The token database will be saved at the following location: Märgistuste andmebaas salvestatakse järgmisesse asukohta: - + Save to a custom path (not recommended) Salvesta enda määratud asukohta (pole soovitatav) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Nukk-komplekt mis sisaldab märgistusi @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle sissetooja @@ -292,22 +306,22 @@ OutroPage - + Finished Valmis - + The wizard has finished. Võlur on lõpetanud. - + You can now start using Cockatrice with the newly updated cards. Nüüd saad Cockatrice’i kasutada uhiuute kaartidega. - + If the card databases don't reload automatically, restart the Cockatrice client. Kui kaardi andmebaasid ei lae ise, taaskäivita Cockatrice’i klient. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Viga - + + No set has been imported. Komplekti pole sisse toodud. - + Sets imported Allalaetud komplektid - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: Leiti järgmised komplektid: - + Press "Save" to store the imported cards in the Cockatrice database. Vajuta „Salvesta“, et salvestada imporditud kaardid Cockatrice’i andmebaasi. - + The card database will be saved at the following location: Kaardi andmebaas salvestatakse järgmisesse asukohta: - + Save to a custom path (not recommended) Salvesta enda määratud asukohta (pole soovitatav) - + &Save &Salvesta - + + Import finished: %1 cards. %1 kaarti imporditi edukalt. - + + %1: %2 cards imported %1: imporditi %2 kaarti - + Save card database Salvesta kaartide andmebaas - + XML; card database (*.xml) XML; kaartide andmebaas (*.xml) - + The file could not be saved to %1 Faili salvestamine asukohta %1 ebaõnnestus @@ -414,8 +431,9 @@ - Downloading (0MB) - Allalaadimine (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,20 +605,20 @@ i18n - + English - Eesti Keel (Estonian) + Eesti (Estonian) main - + Only run in spoiler mode Käivita vaid spoileri režiimis - + Run in no-confirm background mode From f56591079081d6ed065f18a2f470c27f052a6e10 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:37:23 +0200 Subject: [PATCH 059/247] Updates for project Cockatrice and language el (#7552) * Translate oracle_en@source.ts in el [Manual Sync] 52% of minimum 5% translated source file: 'oracle_en@source.ts' on 'el'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in el [Manual Sync] 10% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'el'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_el.ts | 7526 ++++++++++++++++------ oracle/translations/oracle_el.ts | 200 +- 2 files changed, 5667 insertions(+), 2059 deletions(-) diff --git a/cockatrice/translations/cockatrice_el.ts b/cockatrice/translations/cockatrice_el.ts index be66756d9..9e2ec6910 100644 --- a/cockatrice/translations/cockatrice_el.ts +++ b/cockatrice/translations/cockatrice_el.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Ρύθμιση μετρητή... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Ρύθμιση μετρητή - + New value for counter '%1': Νέα τιμή για τον μετρητή '%1' @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error Σφάλμα - + Could not create themes directory at '%1'. - + Theme settings Ρυθμίσεις θέματος - + Current theme: Τρέχον θέμα: - + Open themes folder Άνοιγμα φακέλου θεμάτων - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Απόδοση κάρτας - + Display card names on cards having a picture Εμφανίστε τα ονόματα καρτών σε κάρτες με εικόνα - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Αύξηση της κλίμακας των καρτών με την επιλογή του ποντικιού - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Διάταξη χειρός - + Display hand horizontally (wastes space) Εμφάνιση χεριού οριζόντια (σπατάλη χώρου) - + Enable left justification Ενεργοποιήστε την αριστερή αιτιολόγηση - + Table grid layout Διάταξη πλέγματος πίνακα - + Invert vertical coordinate Ανατροπή κάθετης συντεταγμένης - + Minimum player count for multi-column layout: Ελάχιστος αριθμός παικτών για διάταξη πολλαπλών στηλών: - + Maximum font size for information displayed on cards: Μέγιστο μέγεθος γραμματοσειράς για πληροφορίες που εμφανίζονται στις κάρτες: @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name απαγόρευση και όνομα χρήστη - + ban &IP address απαγόρευση & διεύθυνση IP - + ban client I&D απαγόρευση εξυπηρετητή I&D - + Ban type Τύπος απαγόρευσης - + &permanent ban &μόνιμη απαγόρευση - + &temporary ban &προσωρινή απαγόρευση - + &Days: &Μέρες: - + &Hours: &Ώρες: - + &Minutes: &Λεπτά: - + Duration of the ban Διάρκεια της απαγόρευσης - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Παρακαλώ καταχωρίστε τον λόγο της απαγόρευσης. Αυτό αποθηκεύεται μόνο για τους επόπτες και δεν μπορεί να το δει το αποκλεισμένο άτομο. - + Please enter the reason for the ban that will be visible to the banned person. Παρακαλώ καταχωρίστε τον λόγο της απαγόρευσης που θα είναι ορατή στο αποκλεισμένο άτομο. - + Redact all messages from this user in all rooms - + &OK &Εντάξει - + &Cancel &Άκυρο - + Ban user from server Απαγόρευση χρήστη από τον διακομιστή - - - - + + + + Error Σφάλμα - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Για την απαγόρευση πρέπει να επιλέξετε ένα όνομα, μία IP, ένα clientId, ή κάποιο συνδυασμό των τριών. - + You must have a value in the name ban when selecting the name ban checkbox. Πρέπει να έχετε μια τιμή στην απαγόρευση ονόματος όταν επιλέγετε το πλαίσιο ελέγχου απαγόρευσης ονόματος. - + You must have a value in the ip ban when selecting the ip ban checkbox. Πρέπει να έχετε μια τιμή στην απαγόρευση ip όταν επιλέγετε το πλαίσιο ελέγχου απαγόρευσης ip. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Πρέπει να έχετε μια τιμή στην απαγόρευση clientid όταν επιλέγετε το πλαίσιο ελέγχου απαγόρευσης clientid. @@ -499,22 +634,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. @@ -522,7 +657,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name Όνομα - + Sets Σετς - + Mana cost Το κόστος του Μάνα - + Card type Είδος κάρτας - + P/T P/T - + Color(s) Χρώμα(τα) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -744,22 +1036,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -795,129 +1087,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ This is only saved for moderators and cannot be seen by the banned person. CardSizeWidget - + Card Size @@ -1078,6 +1370,24 @@ This is only saved for moderators and cannot be seen by the banned person. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers - + Success Επιτυχία - + Download URLs have been reset. - + Add URL - - + + URL: - - + + Edit URL - + Add New URL - + Remove URL - + Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - + Reset Download URLs @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number Αριθμός - + Provider ID - + Card Κάρτα @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Φόρτωση deck... - + Load remote deck... Φόρτωση απομακρυσμένου deck... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Σφάλμα - + The selected file could not be loaded. Δεν ήταν δυνατή η φόρτωση του επιλεγμένου αρχείου. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. - + Known Hosts Γνωστοί Hosts - + Delete the currently selected saved server - + Refresh the server list with known public servers - + New Host Νέος Host - + Name: Όνομα: - + &Host: &Host: - + &Port: &Θύρα: - + Player &name: Όνομα &παίχτη: - + P&assword: Κωδικός: - + &Save password &Αποθήκευση Κωδικού - + A&uto connect Α&υτόματη σύνδεση - + Automatically connect to the most recent login when Cockatrice opens Συνδεθείτε αυτόματα στην πιο πρόσφατη σύνδεση όταν ανοίγει το Cockatrice - + If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage - + Reset Password - + Forgot password? - + &Connect - + Server Διακομιστής - + Login Σύνδεση - + Server Contact - + Connect to Server - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning Προειδοποίηση σύνδεσης - + You need to name your new connection profile. Πρέπει να ονομάσετε το νέο προφίλ σύνδεσης. - + Connect Warning Προειδοποίηση σύνδεσης - + The player name can't be empty. Το όνομα παίκτη δεν μπορεί να είναι άδειο. @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Θυ&μηθείτε τις ρυθμίσεις - + &Description: &Περιγραφή: - + P&layers: Π&αίχτες: - + General - + Game type Είδος παιχνιδιού - + &Password: &Κωδικός: - + Only &buddies can join Μόνο &φίλοι μπορούν να συμμετάσχουν - + Only &registered users can join Μόνο &εγγεγραμμένοι χρήστες μπορούν να συμμετάσχουν - + Joining restrictions Περιορισμοί συμμετοχής - + &Spectators can watch Οι &θεατές μπορούν να παρακολουθήσουν - + Spectators &need a password to watch Οι θεατές &χρειάζονται κωδικό για να παρακολουθήσουν - + Spectators can &chat Οι θεατές μπορούν να &συζητήσουν - + Spectators can see &hands Οι θεατές μπορούν να δουν τα &χέρια - + Create game as spectator - + Spectators Θεατές - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear &Καθάρισε - + Create game Δημιουργία παιχνιδιού - + Game information Πληροφορίες παιχνιδιού - + Error Σφάλμα - + Server error. Σφάλμα Διακομιστή. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Όνομα: - + Token Token - + C&olor: Χ&ρώμα: - + white άσπρο - + blue μπλε - + black μαύρο - + red κόκκινο - + green πράσινο - + multicolor πολύχρωμο - + colorless άχρωμο - + &P/T: &P/T: - + &Annotation: &Σχόλιο: - + &Destroy token when it leaves the table &κατέστρεψε το token όταν φύγει από το τραπέζι - + Create face-down (Only hides name) - + Token data Στοιχεία token - + Show &all tokens Εμφάνισε &όλα τα tokens - + Show tokens from this &deck Εμφάνιση tokens από αυτό το &deck - + Choose token from list Επέλεξε token από τη λίστα - + Create token Δημιουργία token @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Δεν έχει επιλεγεί εικόνα. @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. Για να καταργήσετε το τρέχον avatar, επιβεβαιώστε χωρίς να επιλέξετε μια νέα εικόνα. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Εξέτασε... - + Change avatar Αλλαγή avatar - + Open Image Άνοιγμα εικόνας - + Image Files (*.png *.jpg *.bmp) Αρχεία εικόνας (*.png *.jpg *.bmp) - + Invalid image chosen. Επιλέχθηκε μη έγκυρη εικόνα. @@ -2836,38 +3454,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: Παλιός κωδικός: - + New password: Νέος κωδικός: - + Confirm new password: Επιβεβαιώστε τον καινούριο σας κωδικό: - + Change password Αλλαγή κωδικού - - + + Error Σφάλμα - + Your password is too short. Ο κωδικός σας είναι πολύ μικρός. - + The new passwords don't match. Οι νέοι κωδικοί πρόσβασης δεν ταιριάζουν. @@ -2875,93 +3493,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: &Όνομα: - + C&olor: Χ&ρώμα: - + white άσπρο - + blue μπλε - + black μαύρο - + red κόκκινο - + green πράσινο - + multicolor πολύχρωμο - + colorless άχρωμο - + &P/T: &P/T: - + &Annotation: &Σχόλιο: - + Token data Στοιχεία token - - + + Add token Προσθήκη token - + Remove token Αφαίρεση token - + Edit custom tokens - + Please enter the name of the token: Παρακαλώ εισάγετε το όνομα του token: - + Error Σφάλμα - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -3039,7 +3657,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started + Hide started games + Hide games that have started @@ -3075,8 +3694,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - &Όνομα δημιουργού: + &Host name: + &Creator name: + @@ -3142,52 +3762,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: &Host: - + &Port: &Θύρα: - + Player &name: Όνομα &παίχτη: - + Email: Email: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. Η διεύθυνση e-mail δεν μπορεί να είναι άδεια @@ -3195,37 +3815,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: &Host: - + &Port: &Θύρα: - + Player &name: Όνομα &παίχτη: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. Το όνομα παίκτη δεν μπορεί να είναι άδειο. @@ -3233,94 +3853,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: &Host: - + &Port: &Θύρα: - + Player &name: Όνομα &παίχτη: - + Token: Token: - - + + New Password: Νέος κωδικός: - + Reset Password - + The player name can't be empty. Το όνομα παίκτη δεν μπορεί να είναι άδειο. - - - - + + + + Reset Password Error - + The token can't be empty. - + The new password can't be empty. - + Error Σφάλμα - + Your password is too short. - + The passwords do not match. Οι κωδικοί πρόσβασης δεν ταιριάζουν. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3404,165 +4057,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: &Host: - + &Port: &Θύρα: - + Player &name: Όνομα &παίχτη: - + P&assword: - + Password (again): Κωδικός (ξανά): - + Email: Email: - + Email (again): Διεύθυνση e-mail (ξανά): - + Country: Χώρα: - + Undefined Απροσδιόριστο - + Real name: Πραγματικό όνομα: - + Register to server Εγγραφή σε διακομιστή - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Προειδοποίηση Εγγραφής - + Your password is too short. Ο κωδικός σας είναι πολύ μικρός. - + Your passwords do not match, please try again. Οι κωδικοί δεν ταιριάζουν, παρακαλώ προσπαθήστε ξανά - + Your email addresses do not match, please try again. - + The player name can't be empty. Το όνομα παίκτη δεν μπορεί να είναι άδειο. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3628,40 +4682,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Η έκδοση της βάσης δεδομένων των καρτών είναι πολύ παλιά. - -Αυτό μπορεί να προκαλέσει προβλήματα στη φόρτωση πληροφοριών για τις κάρτες ή τις εικόνες - -Συνήθως αυτό φτιάχνεται ξανατρέχοντας το oracle για να επικαιροποιήσετε τη βάση δεδομένων σας. - -Θα θέλατε να αλλάξετε τη ρυθμισμένη θέση της βάσης δεδομένων σας; - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3670,21 +4696,21 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3693,104 +4719,235 @@ Would you like to change your database location setting? - - - + + + Error Σφάλμα - + The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings Ρυθμίσεις - + + Search settings... + + + + + OK + + + + General Γενικά - + Appearance Εμφάνιση - + User Interface Διεπαφή Χρήστη - + Storage - + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat - + Sound Ήχος - + Shortcuts Συντομεύσεις + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3798,17 +4955,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next - + Previous - + Tip of the Day @@ -3816,164 +4973,174 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel Τρέχον κανάλι έκδοσης - + Reinstall Επανεγκατάσταση - + Cancel Download Ακύρωση Κατεβάσματος - + Open Download Page Άνοιγμα Σελίδας Κατεβάσματος - + Check for Client Updates - - - - + + + + Error Σφάλμα - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 - + Checking for updates... - + Finished checking for updates Ο έλεγχος για ενημερώσεις ολοκληρώθηκε - + No Update Available Καμία Διαθέσιμη Ενημέρωση - + Cockatrice is up to date! Το Cockatrice είναι πλήρως ενημερωμένο! - + You are already running the latest version available in the chosen release channel. Ήδη τρέχετε την πιό πρόσφατη έκδοση διαθέσιμη σε αυτό το κανάλι. - + Current version Τρέχουσα έκδοση - + Selected release channel Επιλεγμένο κανάλι έκδοσης - - + + Update Available Υπάρχει Διαθέσιμη Ενημέρωση - - + + A new version of Cockatrice is available! Μία νέα έκδοση του Cockatrice είναι διαθέσιμη! - - + + New version Νέα έκδοση - - + + Released - - + + Changelog Αρχείο Αλλαγών - + Do you want to update now? Θέλετε να ενημερώσετε τώρα; - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3981,17 +5148,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log @@ -4229,7 +5396,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4237,7 +5404,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -4245,45 +5412,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4291,140 +5501,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Σφάλμα - + Please join the appropriate room first. - + Wrong password. Λάθος κωδικός. - + Spectators are not allowed in this game. - + The game is already full. - + The game does not exist any more. - + This game is only open to registered users. - + This game is only open to its creator's buddies. - + You are being ignored by the creator of this game. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game - - Password: - Κωδικός: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. - + &Filter games - + C&lear filter Καθάρισμα Φίλτρου - + C&reate Δημιουργία - + &Join Είσοδος - + Join as judge - + J&oin as spectator Είσοδος ως θεατής - + Join as judge spectator - + Games shown: %1 / %2 - + Games @@ -4465,105 +5691,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password κωδικός - + buddies only μόνο φίλοι - + reg. users only μόνο εγγεγραμμένοι χρήστες - + open decklists - - + + can chat μπορούν να συνομιλήσουν - + see hands βλέπουν τα χέρια - + can see hands μπορούν να δούν τα χέρια - + not allowed δεν επιτρέπεται - + Room Δωμάτιο - + Age Ηλικία - + Description Περιγραφή - - Creator - Δημιουργός + + Host + - + Type Τύπος - + Restrictions Περιορισμοί - + Players Παίκτες - + Spectators Θεατές @@ -4571,158 +5797,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + Ελληνικά (Greek) + + + + + + + + + Choose path Επιλέξτε θέση - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Γλώσσα: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) - + Paths - + How to help with translations - + Decks directory: Φάκελος deck: - + Filters directory: - + Replays directory: Φάκελος replay: - + Pictures directory: Φάκελος εικόνων: - + Card database: Βάση δεδομένων καρτών - + Custom database directory: - + Token database: Βάση δεδομένων token: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4730,47 +6073,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4864,59 +6207,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5134,410 +6771,431 @@ You may have to manually download the new version. MainWindow - - + + Player %1 - + Load replay - + About Cockatrice - + Version Έκδοση - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: - + Our Translators Οι μεταφραστές μας - + Help Translate! - + Support: Υποστήριξη: - + Report an Issue Αναφορά Προβλήματος - + Troubleshooting Αντιμετώπιση Προβλημάτων - + F.A.Q. Συχνές Ερωτήσεις - - + + Error Σφάλμα - + &Connect... Σύνδεση... - + &Disconnect Αποσύνδεση - + Start &local game... Έναρξη τοπικού παιχνιδιού... - + &Watch replay... - + &Full screen Πλήρης οθόνη - + &Register to server... Εγγραφή σε σέρβερ... - + &Restore password... - + &Settings... Ρυθμίσεις... - + &Exit Έξοδος - + A&ctions - + &Cockatrice &Cockatrice - + C&ard Database Βάση δεδομένων καρτών - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help Βοήθεια - + &About Cockatrice Σχετικά με το Cockatrice - + &Tip of the Day - + Check for Client Updates Έλεγχος για ενημερώσεις - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Βάση δεδομένων καρτών - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes Ναι - - + + No Όχι - + Open settings Άνοιγμα ρυθμίσεων - + New sets found Βρέθηκαν νέα σετ - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets Εμφάνιση σετ - + Welcome Καλωσήρθατε - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information Πληροφορίες - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Φόρτωση σετ/καρτών - + Selected file cannot be found. Το επιλεγμένο αρχείο δε βρέθηκε. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. @@ -5742,615 +7400,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play από το παιχνίδι - + from their graveyard - + from exile από την εξορία - + from their hand - + the top card of %1's library την πρώτη κάρτα της βιβλιοθήκης του/της %1 - + the top card of their library - + from the top of %1's library από την κορυφή της βιβλιοθήκης του/της %1 - + from the top of their library - + the bottom card of %1's library την τελευταία κάρτα της βιβλιοθήκης του/της %1 - + the bottom card of their library - + from the bottom of %1's library από το τέλος της βιβλιοθήκης του/της %1 - + from the bottom of their library - + from %1's library από τη βιβλιοθήκη του/της %1 - + from their library - + from sideboard από το sideboard - + from the stack από το stack - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). Ο/Η %1 φόρτωσε ένα deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). Ο/Η %1 φόρτωσε ένα deck με %2 κάτρες στο sideboard (%3). - + %1 destroys %2. - + a card μία κάρτα - + %1 gives %2 control over %3. ο/η %1 δίνει στον/στην %2 τον έλεγχο του %3 . - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. ο/η %1 βάζει στο παιχνίδι το %2%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. ο/η %1 εξορίζει το %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. Το παιχνίδι έχει κλείσει - + The game has started. Το παιχνίδι ξεκίνησε. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. Ο/Η %1 παρακολουθεί το παιχνίδι. - + You have been kicked out of the game. Σας απέβαλαν από το παιχνίδι. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6358,115 +8016,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message - - + + Message: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - + Ignore private messages sent by non-buddy users - - + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6474,42 +8142,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6588,148 +8256,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6852,30 +8506,21 @@ Cockatrice will now reload the card database. - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -6994,16 +8639,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7024,12 +8925,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7037,25 +8938,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7086,28 +9027,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7123,95 +9064,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -7219,17 +9168,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) @@ -7249,17 +9198,22 @@ This setting means you'll only see the default printing for each card, instead o - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7270,7 +9224,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7281,100 +9235,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults @@ -7382,121 +9346,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name - + ID - + Upload time + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7538,37 +9570,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms - + Joi&n - + Room - + Description - + Permissions - + Players - + Games @@ -7576,7 +9608,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7584,32 +9616,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7642,56 +9674,92 @@ Fine-tune individual colours in the grid afterwards. + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7745,12 +9813,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7758,27 +9826,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7786,48 +9854,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error Σφάλμα - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7835,157 +9903,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -7993,17 +10074,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8124,118 +10221,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8248,42 +10345,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8296,52 +10393,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info - + Deck - + Filters - + &View - + Card Database - + Printing - + Visible - + Floating - + Reset layout - + Deck: %1 @@ -8349,49 +10446,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8399,22 +10496,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8422,138 +10519,236 @@ Please check your shortcut settings! TabDeckStorage - + Local file system - + Server deck storage - - + + Open in deck editor - + Rename deck or folder - + Upload deck - + Download deck - - - - + + + + New folder - - + + Delete - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error Σφάλμα - + Rename failed - - + + + Invalid deck file - + Enter deck name - + This decklist does not have a name. Please enter a name: - + Unnamed deck - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: - + Deck Storage @@ -8561,20 +10756,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8587,27 +11033,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8620,191 +11066,207 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + + Invite + + + + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases - + &Game - + Next &phase - + Next phase with &action - + Next &turn - + Reverse turn order - + &Remove all local arrows - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede - + &Leave game - + C&lose replay - + &Focus Chat - + &Say: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede - + Are you sure you want to concede this game? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game - + Are you sure you want to leave this game? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8820,157 +11282,157 @@ Please enter a name: TabLog - + Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs - + Chat Logs - - + + Error Σφάλμα - + You must select at least one filter. - + You have to select a valid number of days to locate. - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8978,218 +11440,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat - + &Leave - + %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. - + %1 has joined the server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -9199,65 +12001,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: - + Chat - + &Room - + &Leave room - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. @@ -9265,35 +12480,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server - - - - + + + + Error Σφάλμα - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9301,97 +12516,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? - + There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9399,38 +12690,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -9449,6 +12740,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9463,6 +12782,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9532,57 +12910,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9590,216 +12963,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details - + Private &chat - + Show this user's &games - - - Add to &buddy list - - - - - Remove from &buddy list - - - - - Add to &ignore list - - - - - Remove from &ignore list - - - - - Kick from &game - - - - - Warn user - - - View user's war&n history + View this user's &public decks - Ban from &server + Add to &buddy list - View user's &ban history + Remove from &buddy list - &Promote user to moderator + Add to &ignore list - Dem&ote user from moderator + Remove from &ignore list - Promote user to &judge + Kick from &game - Demote user from judge + Report user + Warn user + + + + + View user's war&n history + + + + + Ban from &server + + + + + View user's &ban history + + + + + &Promote user to moderator + + + + + Dem&ote user from moderator + + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9837,61 +13260,61 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Moderator - + Registered user - - + + Unregistered user - + Judge - + Unknown - + The entered password does not match your account. - - - - + + + + Information - + User information updated. - - - - - - + + + + + - - - + + + + Error Σφάλμα @@ -9915,283 +13338,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password Εισάγετε κωδικό - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. - - - Password changed. - - - This server does not permit you to change your password. + Password changed. - The new password is too short. + This server does not permit you to change your password. + The new password is too short. + + + + The old password is incorrect. - + Avatar updated. - + This server does not permit you to update your avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10199,223 +13631,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings - + &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - - Use tear-off menus, allowing right click menus to persist on screen + + + Enabled - - Keep game chat focused when clicking in game (Note: disables card view search bar) - - - - - Notifications settings - - - - - Enable notifications in taskbar - - - - - Notify in the taskbar for game events while you are spectating + + + Automatic - Notify in the taskbar when users in your buddy list connect - - - - - Animation settings + CommanderSpellbook bracket names + Official Commander bracket names (approximate) + + + + + Use tear-off menus, allowing right click menus to persist on screen + + + + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + + Notifications settings + + + + + Enable notifications in taskbar + + + + + Notify in the taskbar for game events while you are spectating + + + + + Notify in the taskbar when users in your buddy list connect + + + + + Animation settings + + + + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10452,22 +14005,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10475,72 +14028,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10642,7 +14195,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10650,19 +14203,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10708,28 +14261,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10737,43 +14290,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10789,12 +14342,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10802,17 +14355,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10820,7 +14373,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10828,52 +14381,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10881,7 +14439,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10889,22 +14447,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10912,17 +14470,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10930,177 +14493,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error Σφάλμα - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - - How to use custom card art + + Search by set name, code, type, or release date + How to use custom card art + + + + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -11108,93 +14696,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing - + pile view - - i18n - - - English - Ελληνικά (Greek) - - main - + Connect on startup - + Debug to file @@ -11978,6 +15558,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_el.ts b/oracle/translations/oracle_el.ts index d3a2e081c..8b07a5ace 100644 --- a/oracle/translations/oracle_el.ts +++ b/oracle/translations/oracle_el.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Εισαγωγή - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Αυτός ο αυτόματος οδηγός θα εισάγει τη λίστα των σετ, καρτών και δειγμάτων (tokens) που θα χρησιμοποιηθούν από το Cockatrice. - + Interface language: - + Version: Έκδοση: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Επιλογή πηγής - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: URL λήψης: - + Local file: Τοπικό αρχείο: - + Restore default URL Επαναφορά προκαθορισμένης διεύθυνσης URL - + Choose file... Επιλέξτε αρχείο... - + Load sets file Φόρτωση αρχείων - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Σφάλμα - + The provided URL is not valid. Η παρεχόμενη διεύθυνση URL δεν είναι έγκυρη. - - Downloading (0MB) - Λήψη (0MB) - - - + Please choose a file. Παρακαλώ διαλέξτε έναν αρχείο. - + Cannot open file '%1'. Δεν είναι δυνατό να ανοίξει το αρχείο '% 1'. - - Downloading (%1MB) - Λήψη (% 1MB) - - - + Network error: %1. Σφάλμα δικτύου: % 1. - + Parsing file Ανάλυση αρχείου - + Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. Αποτυχία ανοίγματος αρχείου Zip:% 1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Η εξαγωγή zip απέτυχε: το αρχείο Zip δεν περιέχει ακριβώς ένα αρχείο. - + Zip extraction failed: %1. Η εξαγωγή zip απέτυχε: % 1. - + Sorry, this version of Oracle does not support zipped files. Λυπούμαστε, αυτή η έκδοση του Oracle δεν υποστηρίζει αρχεία τύπου zipped. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. Το αρχείο ανακτήθηκε με επιτυχία, αλλά δεν περιέχει σύνολα δεδομένων. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database - + XML; spoiler database (*.xml) - + spoiler - + Spoilers import - + Please specify a compatible source for spoiler data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + Tokens import - + Please specify a compatible source for token data. - + Download URL: URL λήψης: - + Local file: - + Restore default URL Επαναφορά προκαθορισμένης διεύθυνσης URL - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Εικονικό σετ που περιέχει tokens @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Εισαγωγέας Oracle @@ -292,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Σφάλμα - + + No set has been imported. Δεν έχει εισαχθεί κανένα σετ. - + Sets imported Εισαγόμενα σετς - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. Η εισαγωγή ολοκληρώθηκε: % 1 κάρτες. - + + %1: %2 cards imported %1: %2 κάρτες εισήχθησαν - + Save card database Αποθήκευση κάρτας βάσης δεδομένων - + XML; card database (*.xml) XML; κάρτα βάσης δεδομένων (* .xml) - + The file could not be saved to %1 Δεν ήταν δυνατή η αποθήκευση του αρχείου στο %1 @@ -414,7 +431,8 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) @@ -587,7 +605,7 @@ i18n - + English Ελληνικά (Greek) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode From a1aa3d9aa9a153bfe973899f84d7506aec60ec0f Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:38:09 +0200 Subject: [PATCH 060/247] Updates for project Cockatrice and language pt (#7563) * Translate oracle_en@source.ts in pt [Manual Sync] 50% of minimum 5% translated source file: 'oracle_en@source.ts' on 'pt'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in pt [Manual Sync] 21% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'pt'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_pt.ts | 7500 ++++++++++++++++------ oracle/translations/oracle_pt.ts | 200 +- 2 files changed, 5651 insertions(+), 2049 deletions(-) diff --git a/cockatrice/translations/cockatrice_pt.ts b/cockatrice/translations/cockatrice_pt.ts index 912ee09d0..12cd17f74 100644 --- a/cockatrice/translations/cockatrice_pt.ts +++ b/cockatrice/translations/cockatrice_pt.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Definir marcador @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings Definições do Tema - + Current theme: Tema actual: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Rendering da carta - + Display card names on cards having a picture Mostrar o nome em cartas com imagem - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Aumentar as cartas ao passar o rato - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Disposição da Mão - + Display hand horizontally (wastes space) Mostrar mão horizontalmente (desperdiça espaço) - + Enable left justification Permitir justificação de texto à esquerda - + Table grid layout Esquema da mesa - + Invert vertical coordinate Inverter coordenada vertical - + Minimum player count for multi-column layout: Número mínimo de kogadores para layout com múltiplas colunas: - + Maximum font size for information displayed on cards: @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Banir nome de &utilizador - + ban &IP address Banir endereço de &IP - + ban client I&D banir I&D do cliente - + Ban type Tipo de Ban - + &permanent ban Ban &permanente - + &temporary ban Ban &temporário - + &Days: &Dias: - + &Hours: &Horas: - + &Minutes: &Minutos: - + Duration of the ban Duração do ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Por favor introduza o motivo do ban. Isto apenas é guardado para os moderadores e não é visível para a pessoa banida. - + Please enter the reason for the ban that will be visible to the banned person. Por favor introduza o motivo do ban que será visível à pessoa banida. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancelar - + Ban user from server Banir utilizador do servidor - - - - + + + + Error Erro - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Tem de seleccionar um banimento baseado no nome, no IP ou ambos. - + You must have a value in the name ban when selecting the name ban checkbox. Deve preencher o campo de nome a banir quando selecciona a caixa de verificação para banir um nome. - + You must have a value in the ip ban when selecting the ip ban checkbox. Deve preencher o campo de IP a banir quando selecciona a caixa de verificação para banir um IP. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Deve preencher o campo de identificação de cliente quando selecciona a caixa de verificação para banir uma identificação de cliente. @@ -499,22 +634,22 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. @@ -522,7 +657,20 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardDatabaseModel - + Name Nome - + Sets Expansões - + Mana cost Custo de mana - + Card type Tipo de carta - + P/T P/R - + Color(s) Cor(es) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -744,22 +1036,22 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -795,129 +1087,129 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardSizeWidget - + Card Size @@ -1078,6 +1370,24 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers - + Success - + Download URLs have been reset. - + Add URL - - + + URL: - - + + Edit URL - + Add New URL - + Remove URL - + Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - + Reset Download URLs @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number Número - + Provider ID - + Card Carta @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... &Carregar deck... - + Load remote deck... Carregar deck remoto... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Erro - + The selected file could not be loaded. O ficheiro seleccionado não pôde ser carregado. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. - + Known Hosts - + Delete the currently selected saved server - + Refresh the server list with known public servers - + New Host Novo anfitrião - + Name: - + &Host: &Servidor: - + &Port: &Porta: - + Player &name: &Nome do jogador: - + P&assword: P&assword: - + &Save password &Guardar password - + A&uto connect Conectar a&utomaticamente no arranque - + Automatically connect to the most recent login when Cockatrice opens Conectar automaticamente ao mais recente login ao abrir o Cockatrice - + If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage - + Reset Password - + Forgot password? - + &Connect - + Server Servidor - + Login Login - + Server Contact - + Connect to Server - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning Aviso de conexão - + The player name can't be empty. O nome de utilizador tem de ser inserido. @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Re&lembrar definições. - + &Description: &Descrição: - + P&layers: &Jogadores: - + General - + Game type Tipo de jogo - + &Password: &Password: - + Only &buddies can join Apenas &amigos podem entrar - + Only &registered users can join Apenas utilizadores &registados podem entrar - + Joining restrictions Restrições para ligar - + &Spectators can watch E&spectadores podem ver - + Spectators &need a password to watch Espectadores &necessitam de palavra-passe - + Spectators can &chat Espectadores podem c&onversar - + Spectators can see &hands &Espectadores podem ver as mãos - + Create game as spectator - + Spectators Espectadores - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear &Limpar - + Create game Criar jogo - + Game information Informação do jogo - + Error Erro - + Server error. Erro do servidor. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nome: - + Token Ficha - + C&olor: C&or: - + white branco - + blue azul - + black preto - + red vermelho - + green verde - + multicolor multicolor - + colorless incolor - + &P/T: &P/R: - + &Annotation: &Nota: - + &Destroy token when it leaves the table &Destruir ficha quando ela deixar a mesa - + Create face-down (Only hides name) - + Token data Informação de ficha - + Show &all tokens Mostrar &todas as fichas - + Show tokens from this &deck Mostrar &fichas deste deck - + Choose token from list Escolha ficha da lista - + Create token Criar ficha @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Não escolheu nenhuma imagem. @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. Para remover o seu avatar actual, confirme SEM escolher uma nova imagem. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Procurar... - + Change avatar Alterar avatar - + Open Image Abrir Imagem - + Image Files (*.png *.jpg *.bmp) Ficheiros de Imagem (*.png *.jpg *.bmp) - + Invalid image chosen. Escolheu uma imagem inválida. @@ -2836,38 +3454,38 @@ Para remover o seu avatar actual, confirme SEM escolher uma nova imagem. DlgEditPassword - + Old password: Password antiga: - + New password: Nova password: - + Confirm new password: Confirme a nova password: - + Change password Alterar a password - - + + Error Erro - + Your password is too short. - + The new passwords don't match. As novas passwords que digitou não coincidem. @@ -2875,93 +3493,93 @@ Para remover o seu avatar actual, confirme SEM escolher uma nova imagem. DlgEditTokens - + &Name: &Nome: - + C&olor: C&or: - + white branco - + blue azul - + black preto - + red vermelho - + green verde - + multicolor multicolor - + colorless incolor - + &P/T: &P/R: - + &Annotation: &Nota: - + Token data Informação de ficha - - + + Add token Adicionar ficha - + Remove token Remover ficha - + Edit custom tokens - + Please enter the name of the token: Por favor introduza o nome da ficha: - + Error Erro - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -3039,7 +3657,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started + Hide started games + Hide games that have started @@ -3075,8 +3694,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - &Nome do criador: + &Host name: + &Creator name: + @@ -3142,52 +3762,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: - + &Port: - + Player &name: - + Email: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. @@ -3195,37 +3815,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: - + &Port: - + Player &name: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. @@ -3233,94 +3853,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: - + &Port: - + Player &name: - + Token: - - + + New Password: - + Reset Password - + The player name can't be empty. - - - - + + + + Reset Password Error - + The token can't be empty. - + The new password can't be empty. - + Error - + Your password is too short. - + The passwords do not match. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3404,165 +4057,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: &Servidor: - + &Port: &Porta: - + Player &name: &Nome do jogador: - + P&assword: P&assword: - + Password (again): Password (novamente): - + Email: Email: - + Email (again): Email (novamente): - + Country: País: - + Undefined Indefinido - + Real name: Nome real: - + Register to server Registe-se no server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Registo recusado - + Your password is too short. - + Your passwords do not match, please try again. As passwords que digitou não coincidem. - + Your email addresses do not match, please try again. Os emails que digitou não coincidem. - + The player name can't be empty. Tem de escolher um nome de utilizador. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3628,46 +4682,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Erro desconhecido ao carregar a base de dados das cartas - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Base de dados de cartas inválida. - -Cockatrice pode não funcionar correctamente com uma base de dados inválida. - -Poderá necessitar de correr o Oracle para actualizar a base de dados. - -Gostaria de alterar a localização da base de dados? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - A versão da base de dados está desactualizada. - -Poderá causar problemas ao carregar a informação ou a imagem das cartas - -Pode ser reparado correndo o Oracle de novo para actualizar a base de dados das cartas. - -Gostaria de alterar a localização da base de dados? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3676,7 +4696,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3685,7 +4705,7 @@ Would you like to change your database location setting? Gostaria de alterar a localização da base de dados? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3694,7 +4714,7 @@ Would you like to change your database location setting? Gostaria de alterar a localização da base de dados? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3703,104 +4723,235 @@ Would you like to change your database location setting? - - - + + + Error Erro - + The path to your deck directory is invalid. Would you like to go back and set the correct path? O directório do seu deck é inválido. Gostaria de voltar atrás e corrigir o directório? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? O directório das imagens das cartas é inválido. Gostaria de voltar atrás e corrigir o directório? - + Settings Definições - + + Search settings... + + + + + OK + + + + General Geral - + Appearance Aparência - + User Interface Interface do utilizador - + Storage - + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Som - + Shortcuts Atalhos + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3808,17 +4959,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next - + Previous - + Tip of the Day @@ -3826,164 +4977,174 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel - + Reinstall - + Cancel Download - + Open Download Page Abrir a página de downloads - + Check for Client Updates - - - - + + + + Error Erro - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 - + Checking for updates... A procurar actualizações... - + Finished checking for updates - + No Update Available - + Cockatrice is up to date! - + You are already running the latest version available in the chosen release channel. - + Current version - + Selected release channel - - + + Update Available - - + + A new version of Cockatrice is available! - - + + New version - - + + Released - - + + Changelog - + Do you want to update now? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Erro na actualização - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... A instalar... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3991,17 +5152,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log Registo de erros @@ -4239,7 +5400,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4247,7 +5408,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -4255,45 +5416,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4301,140 +5505,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Erro - + Please join the appropriate room first. Por favor entre na sala apropriada primeiro. - + Wrong password. Password incorrecta. - + Spectators are not allowed in this game. Não são permitidos espectadores neste jogo. - + The game is already full. O jogo já se encontra cheio. - + The game does not exist any more. O jogo já não existe. - + This game is only open to registered users. Este jogo só está aberto a utilizadores registados. - + This game is only open to its creator's buddies. Este jogo só está aberto aos amigos do seu criador. - + You are being ignored by the creator of this game. Você está a ser ignorado pelo criador deste jogo. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game Entrar no jogo - - Password: - Password: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Por favor entre na sala respectiva primeiro. - + &Filter games &Filtrar jogos - + C&lear filter &Limpar filtros - + C&reate C&riar - + &Join &Entrar - + Join as judge - + J&oin as spectator Entrar como &espectador - + Join as judge spectator - + Games shown: %1 / %2 - + Games Jogos @@ -4475,105 +5695,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password senha - + buddies only amigos apenas - + reg. users only utilizadores registados apenas - + open decklists - - + + can chat pode falar - + see hands ver mãos - + can see hands pode ver as mãos - + not allowed não permitidos - + Room Sala - + Age Idade - + Description Descrição - - Creator - Criador + + Host + - + Type Tipo - + Restrictions Restrições - + Players Jogadores - + Spectators Espectadores @@ -4581,158 +5801,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + Português (Portuguese) + + + + + + + + + Choose path Escolher directório - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Língua: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Diretórios (edição desativada em modo portátil) - + Paths Directórios - + How to help with translations - + Decks directory: Directório dos decks: - + Filters directory: - + Replays directory: Directório de replays: - + Pictures directory: Directório das imagens: - + Card database: Base de dados das cartas: - + Custom database directory: - + Token database: Base de dados das fichas: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4740,47 +6077,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4874,59 +6211,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5144,271 +6775,292 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Jogador %1 - + Load replay Carregar replay - + About Cockatrice Sobre o Cockatrice - + Version - + Cockatrice Webpage Website Cockatrice - + Project Manager: Gestor do projecto: - + Past Project Managers: Anteriores gestores do projecto: - + Developers: Desenvolvedores: - + Our Developers Os nossos desenvolvedores - + Help Develop! Ajuda Desenvolvimento! - + Translators: Tradutores: - + Our Translators - + Help Translate! Ajude a traduzir! - + Support: Ajuda técnica: - + Report an Issue Reportar um Problema - + Troubleshooting Solução de problemas - + F.A.Q. Perguntas frequentes - - + + Error Erro - + &Connect... &Ligar... - + &Disconnect &Desligar - + Start &local game... Começar &jogo local... - + &Watch replay... &Ver replay... - + &Full screen Ecrã &inteiro - + &Register to server... &A registar no servidor... - + &Restore password... - + &Settings... &Configurações... - + &Exit &Sair - + A&ctions A&cções - + &Cockatrice &Cockatrice - + C&ard Database B&ase de dados de cartas - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder Abrir a pasta de imagens personalizadas - + Open custom sets folder Abrir a pasta de expansões personalizadas - + Add custom sets/cards Adicione cartas/expansões personalizadas - + Reload card database - + Tabs - + &Help &Ajuda - + &About Cockatrice S&obre o Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + Tens a certeza? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Base de dados de cartas - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5417,140 +7069,140 @@ Deseja actualizar a sua base de dados agora?%s Se não tem a certeza ou é a primeira vez que está a utilizar o Cockatrice, escolha "Sim" - - + + Yes Sim - - + + No Não - + Open settings Abrir definições - + New sets found Novas expansões foram encontradas - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets Ver expansões - + Welcome Bem-vindo/a - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information Informação - + A card database update is already running. A actualização da base de dados das cartas já está a correr. - + Unable to run the card database updater: Incapaz de correr o actualizador da base de dados das cartas: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Carregar expansões/cartas - + Selected file cannot be found. O ficheiro escolhido não foi encontrado. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Actualização completa com sucesso. %s O Cockatrice irá agora recarregar a base de dados das cartas. - + Sets/cards failed to import. Falha na importação de expansões/cartas. @@ -5755,615 +7407,615 @@ O Cockatrice irá agora recarregar a base de dados das cartas. MessageLogWidget - + from play do jogo - + from their graveyard do seu cemitério - + from exile vindo do exílio - + from their hand da sua mão - + the top card of %1's library a carta do topo do grimório de %1 - + the top card of their library a carta do topo dos seus grimórios - + from the top of %1's library do topo do grimório de %1 - + from the top of their library do topo dos seus grimórios - + the bottom card of %1's library a carta do fundo do grimório de %1 - + the bottom card of their library a carta do fundo dos seus grimórios - + from the bottom of %1's library do fundo do grimório de %1 - + from the bottom of their library do fundo dos seus grimórios - + from %1's library do grimório de %1 - + from their library dos seus grimórios - + from sideboard do sideboard - + from the stack da pilha - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 está agora a manter a carta do topo %2 revelada. - + %1 is not revealing the top card %2 any longer. %1 já não está a manter a carta do topo %2 revelada. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 anexa %2 a %4 de %3. - + %1 has conceded the game. %1 concedeu o jogo. - + %1 has unconceded the game. - + %1 has restored connection to the game. %1 restabeleceu a ligação ao jogo. - + %1 has lost connection to the game. %1 perdeu a ligação ao jogo. - + %1 points from their %2 to themselves. %1 aponta do seu %2 para si próprio. - + %1 points from their %2 to %3. %1 aponta do seu %2 para %3. - + %1 points from %2's %3 to themselves. %1 aponta do %3 de %2 para si própria. - + %1 points from %2's %3 to %4. %1 aponta de %3 de %2 para %4. - + %1 points from their %2 to their %3. %1 aponta do seu %2 para %3. - + %1 points from their %2 to %3's %4. %1 aponta do seu %2 para o %4 de %3. - + %1 points from %2's %3 to their own %4. %1 aponta de %3 de %2 para o seu %4. - + %1 points from %2's %3 to %4's %5. %1 aponta de %3 de %2 para %5 de %4. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 cria ficha: %2%3. - + %1 has loaded a deck (%2). %1 carregou um deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 carregou um deck com %2 cartas na sideboard (%3). - + %1 destroys %2. %1 destrói %2. - + a card uma carta - + %1 gives %2 control over %3. %1 dá controlo sobre %3 a %2. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 coloca %2 em jogo %3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 coloca %2%3 no seu grimório. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exila %2%3. - + %1 moves %2%3 to their hand. %1 move %2%3 para a sua mão. - + %1 puts %2%3 into their library. %1 coloca %2%3 no seu grimório. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1 coloca %2%3 no topo do seu grimório. - + %1 puts %2%3 into their library %4 cards from the top. %1 põe %2%3 no seu grimório, %4 cartas a contar do topo. - + %1 moves %2%3 to sideboard. %1 move %2%3 para o sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 joga %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. %1 está a olhar para %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. Este jogo foi encerrado. - + The game has started. O jogo começou. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 entrou no jogo. - + %1 is now watching the game. %1 está agora a ver o jogo. - + You have been kicked out of the game. Você foi expulso do jogo. - + %1 has left the game (%2). %1 abandonou o jogo (%2). - + %1 is not watching the game any more (%2). %1 não está mais a observar o jogo (%2). - + %1 is not ready to start the game any more. %1 já não está pronto a começar o jogo. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. Está a ver um replay do jogo #%1. - + %1 is ready to start the game. %1 está pronto a começar o jogo. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 revela %2 a %3. - + %1 reveals %2. %1 revela %2. - + %1 randomly reveals %2%3 to %4. %1 revela aleatoreamente %2%3. a %4. - + %1 randomly reveals %2%3. %1 revela aleatoreamente %2%3. - + %1 peeks at face down card #%2. %1 espreita a carta virada para baixo #%2. - + %1 peeks at face down card #%2: %3. %1 espreita a carta virada para baixo #%2: %3. - + %1 reveals %2%3 to %4. %1 revela %2%3 a %4. - + %1 reveals %2%3. %1 revela %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads Cara - + Tails Coroa - + %1 flipped a coin. It landed as %2. %1 atirou uma moeda ao ar. Calhou %2. - + %1 rolls a %2 with a %3-sided die. %1 obteve %2 com um dado de %3 faces. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. Turno de %1. - + %1 sets annotation of %2 to %3. %1 coloca uma nota de %2 em%3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 altera o número de marcadores %2 para %3(%4%5). - + %1 sets %2 to not untap normally. %1 define %2 para não desvirar normalmente. - + %1 sets %2 to untap normally. %1 define %2 para desvirar normalmente. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 bloqueou o seu sideboard. - + %1 has unlocked their sideboard. %1 desbloqueou o seu sideboard. - + %1 taps their permanents. %1 vira as suas permanentes. - + %1 untaps their permanents. %1 desvira as suas permanentes. - + %1 taps %2. %1 vira %2. - + %1 untaps %2. %1 desvira %2. - + %1 shuffles %2. %1 baralha %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 desanexa %2. - + %1 undoes their last draw. %1 desfaz a sua última compra. - + %1 undoes their last draw (%2). %1 desfaz a sua última compra (%2). @@ -6371,115 +8023,125 @@ O Cockatrice irá agora recarregar a base de dados das cartas. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Adicionar mensagem - - + + Message: Mensagem: - + Edit message - + Chat settings Definições do Chat - + Custom alert words Palavras alerta personalizáveis - + Enable chat mentions Permitir menções no chat - + Enable mention completer Permitir complementação de menções - + In-game message macros Macros de mensagem no decorrer do jogo - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignorar mensagens de chat enviadas por utilizadores não registados - + Ignore private messages sent by unregistered users Ignorar mensagens privadas enviadas por utilizadores não registados - + Ignore private messages sent by non-buddy users - - + + Invert text color Inverter cor do texto - + Enable desktop notifications for private messages Permitir notificações de mensagens privadas no Ambiente de Trabalho - + Enable desktop notification for mentions Permitir notificações de menções no ambiente de trabalho - + Enable room message history on join Permitir o histórico de mensagens de uma sala ao entrar - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (A cor é hexadecimal) - + Separate words with a space, alphanumeric characters only Palavras separadas com espaço, apenas caracteres alfanuméricos @@ -6487,42 +8149,42 @@ O Cockatrice irá agora recarregar a base de dados das cartas. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6601,148 +8263,134 @@ O Cockatrice irá agora recarregar a base de dados das cartas. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6865,30 +8513,21 @@ O Cockatrice irá agora recarregar a base de dados das cartas. Fase Final - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - pt - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7007,16 +8646,272 @@ O Cockatrice irá agora recarregar a base de dados das cartas. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7037,12 +8932,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7050,25 +8945,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7099,28 +9034,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7136,95 +9071,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Serviços - + Hide %1 Ocultar %1 - + Hide Others Ocultar outros - + Show All Mostrar tudo - + Preferences... Preferências... - + Quit %1 Sair %1 - + About %1 Cerca %1 @@ -7232,17 +9175,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Base de dados de cartas Cockatrice (*.xml) - + All files (*.*) Todos os ficheiros (*.*) - + Cockatrice replays (*.cor) Replays do Cockatrice (*.cor) @@ -7262,17 +9205,22 @@ This setting means you'll only see the default printing for each card, instead o Tokens - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7283,7 +9231,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7294,100 +9242,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults @@ -7395,121 +9353,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Nome - + ID ID - + Upload time Hora de upload + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Nome - + Players Jogadores - + Keep Keep - + Time started Tempo de começo - + Duration (sec) Duração (seg) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7551,37 +9577,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Salas - + Joi&n E&ntrar - + Room Sala - + Description Descrição - + Permissions Permissões - + Players Jogadores - + Games Jogos @@ -7589,7 +9615,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7597,32 +9623,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7655,56 +9681,92 @@ Fine-tune individual colours in the grid afterwards. Data de lançamento + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7758,12 +9820,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7771,27 +9833,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Permitir &sons - + Current sounds theme: Tema sonoro actual: - + Test system sound engine Teste motor de sistema de som - + Sound settings Definições de Som - + Master volume Volume mestre @@ -7799,48 +9861,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7848,157 +9910,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8006,17 +10081,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8137,118 +10228,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8261,42 +10352,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8309,52 +10400,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info Informação da carta - + Deck Baralho - + Filters Filtros - + &View &Vista - + Card Database - + Printing - + Visible Visível - + Floating Flutuando - + Reset layout Repor disposição - + Deck: %1 Deck:%1 @@ -8362,49 +10453,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8412,22 +10503,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8435,139 +10526,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Ficheiros locais - + Server deck storage Decks armazenados no servidor - - + + Open in deck editor Abrir no editor de decks - + Rename deck or folder - + Upload deck Upload do deck - + Download deck Download do deck - - - - + + + + New folder Nova pasta - - + + Delete Apagar - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name Introduza o nome do deck - + This decklist does not have a name. Please enter a name: Este deck nao tem um nome Por favor introduza um nome: - + Unnamed deck Deck sem nome - + Failed to upload deck to server - + Delete local file Apagar ficheiro local - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Nome da nova pasta: - + Deck Storage @@ -8575,20 +10764,271 @@ Por favor introduza um nome: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8601,27 +11041,27 @@ Por favor introduza um nome: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8634,191 +11074,207 @@ Por favor introduza um nome: TabGame - - - + + + Replay Repetição - - + + Game Jogo - - + + Player List Jogadores - - + + + Invite + + + + + Card Info Informação da carta - - + + Messages Mensagens - - + + Replay Timeline Repetir Cronologia - + &Phases Fa&ses - + &Game &Jogo - + Next &phase Próxima &fase - + Next phase with &action - + Next &turn Próximo &turno - + Reverse turn order - + &Remove all local arrows &Remover todas as setas locais - + Rotate View Cl&ockwise Rodar a vista no se&ntido dos ponteiros - + Rotate View Co&unterclockwise rodar a vista no se&ntido contrário aos ponteiros - + Game &information &Informação do jogo - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede &Conceder - + &Leave game Sair do &jogo - + C&lose replay &Fechar replay - + &Focus Chat - + &Say: &Dizer: - + Selected cards - + &View &Vista - + Visible Visível - + Floating Flutuando - + Reset layout Repor disposição - + Concede Conceder - + Are you sure you want to concede this game? Tem a certeza que deseja conceder este jogo? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Sair do jogo - + Are you sure you want to leave this game? Tem a certeza que deseja sair deste jogo? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. Foi expulso do jogo. @@ -8834,158 +11290,158 @@ Por favor introduza um nome: TabLog - + Logs Registos - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Tempo;NomeEnviador;IPEnviador;Mensagem;IDAlvo;NomeAlvo - + Room Logs Registos das salas - + Game Logs Registos dos jogos - + Chat Logs Registos das conversas - - + + Error Erro - + You must select at least one filter. Deve seleccionar pelo menos um filtro - + You have to select a valid number of days to locate. Deve seleccionar um número válido de dias para localizar. - + Username: Nome de utilizador: - + IP Address: Morada IP: - + Game Name: Nome do Jogo: - + GameID: ID do Jogo - + Message: Mensagem: - + Main Room Sala principal - + Game Room Sala de Jogo - + Private Chat Chat Privado - + Past X Days: Últimos X Dias: - + Today Hoje - + Last Hour Última Hora - + Maximum Results: Resultados Máximos: - + At least one filter is required. The more information you put in, the more specific your results will be. É requerido pelo menos um filtro. %s Quanta mais informação der, mais específicos serão os resultados. - + Get User Logs Obter registos dos utilizadores - + Clear Filters Limpar Filtros - + Filters Filtros: - + Log Locations Localização dos Registos - + Date Range Alcance de dados - + Maximum Results Resultados máximos - - + + Message History Histórico de mensagens - + Failed to collect message history information. Falha na obtenção do histórico de mensagens - + There are no messages for the selected filters. Não há mensagens para os filtros seleccionados @@ -8993,218 +11449,558 @@ Quanta mais informação der, mais específicos serão os resultados. TabMessage - + Private &chat Chat &privado - + &Leave &Abandonar - + %1 - Private chat %1 - Chat privado - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1 abandonou o servidor. - + %1 has joined the server. %1 entrou no servidor. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Ficheiros locais - + Server replay storage Armazenamento de replays no servidor - - + + Watch replay Ver replay - + Rename - - + + New folder - - + + Delete Apagar - + Open replays folder - + Download replay Download replay - + Toggle expiration lock Alternar bloqueio de expiração - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file Apagar ficheiro local - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Apagar replay remoto @@ -9214,65 +12010,478 @@ Quanta mais informação der, mais específicos serão os resultados. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Dizer: - + Chat Chat - + &Room &Sala - + &Leave room &Abandonar a sala - + &Clear chat &Limpar chat - + Chat Settings... Definições do chat... - + mentioned you. mencionou-o. - + Click to view Clicar para ver - + You are flooding the chat. Please wait a couple of seconds. Estás a inundar o chat .Por favor aguarde alguns segundos. @@ -9280,35 +12489,35 @@ Quanta mais informação der, mais específicos serão os resultados. TabServer - + Server Servidor - - - - + + + + Error Erro - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9316,97 +12525,173 @@ Quanta mais informação der, mais específicos serão os resultados. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Tem a certeza? - + There are still open games. Are you sure you want to quit? Ainda estão a decorrer alguns jogos. Tem a certeza que quer sair? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9414,39 +12699,39 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion Promoção - + You have been promoted. Please log out and back in for changes to take effect. - + Warned Avisado - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Recebeu um aviso devido a %1. %s Por favor evite continuar essa actividade ou acções serão tomadas contra si futuramente. Se tem alguma questão, por favor envie uma mensagem privada a um moderador. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -9465,6 +12750,34 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9479,6 +12792,65 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f Incapaz de analisar o baralho. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9548,57 +12920,52 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9606,216 +12973,266 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f UserContextMenu - + User &details Detalhes do &utilizador - + Private &chat Chat &privado - + Show this user's &games Mostrar os &jogos deste utilizador - + + View this user's &public decks + + + + Add to &buddy list Adicionar à &lista de amigos - + Remove from &buddy list Remover da lista de &amigos - + Add to &ignore list Adicionar a lista a i&gnorar - + Remove from &ignore list Remover da lista a &ignorar - + Kick from &game Expulsar do &jogo - - - Warn user - Avisar utilizador - - - - View user's war&n history - Ver o histórico de avi&sos do utilizador - - - - Ban from &server - Banir do &servidor - - - - View user's &ban history - Ver o histórico de &bans do utilizador - - - - &Promote user to moderator - &Promover utilizador a moderador - - - - Dem&ote user from moderator - &Despromover utilizador do cargo de moderador - - - - Promote user to &judge - - - Demote user from judge + Report user + Warn user + Avisar utilizador + + + + View user's war&n history + Ver o histórico de avi&sos do utilizador + + + + Ban from &server + Banir do &servidor + + + + View user's &ban history + Ver o histórico de &bans do utilizador + + + + &Promote user to moderator + &Promover utilizador a moderador + + + + Dem&ote user from moderator + &Despromover utilizador do cargo de moderador + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games jogos de %1 - - - + + + Ban History Histórico de banições - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Hora de Banição; Moderador; Duração da Banição; Razão da Banição; Razão visível - + User has never been banned. O utilizador nunca foi banido. - + Failed to collect ban information. - - - + + + Warning History Histórico de avisos - + Warning Time;Moderator;User Name;Reason Hora do aviso; Moderador; Nome do Utilizador; Razão - + User has never been warned. O utilizador nunca foi avisado. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success Sucesso - + Successfully promoted user. Utilizador promovido com sucesso. - + Successfully demoted user. Utilizador despromovido com sucesso. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Falhou - + Failed to promote user. Falha na promoção do utilizador. - + Failed to demote user. Falha na despromoção do utilizador. - + Copy hash to clipboard - + Remove this user's messages @@ -9853,61 +13270,61 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f Administrador - + Moderator Moderador - + Registered user Utilizador registado - - + + Unregistered user Utilizador não registado - + Judge - + Unknown Desconhecido - + The entered password does not match your account. - - - - + + + + Information Informação - + User information updated. Informação do utilizador actualizada. - - - - - - + + + + + - - - + + + + Error Erro @@ -9931,283 +13348,292 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Este servidor não permite a actualização da sua informação pessoal. - + Password changed. Password alterada. - + This server does not permit you to change your password. Este servidor não permite a actualização da sua password. - + The new password is too short. A password nova é demasiado curta. - + The old password is incorrect. A password antiga está incorrecta. - + Avatar updated. Avatar actualizado. - + This server does not permit you to update your avatar. Este servidor não permite a actualização do seu avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Este servidor não permite a actualização da sua informação pessoal. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10215,223 +13641,344 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f UserInterfaceSettingsPage - + General interface settings Configurações gerais da interface - + &Double-click cards to play them (instead of single-click) Clicar &duas vezes nas cartas para as jogar (ao invés de clicar apenas uma vez) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Jogar todos os não-terrenos para a pilha (em vez de para o campo de batalha) por definição - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Permitir anotações em tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings - + Enable notifications in taskbar Permitir notificações na barra de tarefas - + Notify in the taskbar for game events while you are spectating Notificar eventos de jogos na barra de tarefas - + Notify in the taskbar when users in your buddy list connect - + Animation settings Configurações de Animações - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation Animação de &virar/desvirar - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10468,22 +14015,22 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10491,72 +14038,72 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10658,7 +14205,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10666,19 +14213,19 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10724,28 +14271,28 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10753,43 +14300,43 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10805,12 +14352,12 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10818,17 +14365,17 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10836,7 +14383,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10844,52 +14391,57 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10897,7 +14449,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10905,22 +14457,22 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10928,17 +14480,22 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10946,177 +14503,202 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f WarningDialog - + Which warning would you like to send? Que aviso gostaria de enviar? - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancelar - + Warn user for misconduct Aviso por má conduta - - + + Error Erro - + User name to send a warning to can not be blank, please specify a user to warn. O nome de utilizador não se pode encontrar vazio, por favor especifique o utilizador que deseja avisar. - + Warning to use can not be blank, please select a valid warning to send. O aviso a enviar não se pode encontrar vazio, por favor seleccione um aviso válido para enviar. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Mover expansão seleccionada para o topo - + Move selected set up Mover expansão seleccionada para cima - + Move selected set down Mover expansão seleccionada para baixo - + Move selected set to the bottom Mover expansão seleccionada para o fundo - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets Permitir todas as expansões - + Disable all sets Inactivar todas as expansões - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - - How to use custom card art + + Search by set name, code, type, or release date + How to use custom card art + + + + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -11124,93 +14706,85 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing baralhar quando terminar - + pile view vista em pilha - - i18n - - - English - Português (Portuguese) - - main - + Connect on startup - + Debug to file @@ -11994,6 +15568,16 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f Graveyard Cemitério + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_pt.ts b/oracle/translations/oracle_pt.ts index 069f3013e..c0fb580c4 100644 --- a/oracle/translations/oracle_pt.ts +++ b/oracle/translations/oracle_pt.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Introdução - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. - + Interface language: - + Version: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Selecção da fonte - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: URL de download: - + Local file: Ficheiro local: - + Restore default URL URL para repor definições de origem - + Choose file... Escolher ficheiro... - + Load sets file Carregar ficheiro das edições - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Erro - + The provided URL is not valid. O URL fornecido não é válido. - - Downloading (0MB) - A efectuar download (0MB) - - - + Please choose a file. Por favor escolha um ficheiro. - + Cannot open file '%1'. Impossível abrir ficheiro '%1'. - - Downloading (%1MB) - A efectuar download (%1MB) - - - + Network error: %1. Erro da rede: %1. - + Parsing file Ficheiro de análise - + Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. Abrir archivo zip falhou: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Extracção do ZIP falhada: o arquivo ZIP não contem exactamente um ficheiro. - + Zip extraction failed: %1. Extração do Zip falhada: %1. - + Sorry, this version of Oracle does not support zipped files. Pedimos desculpa, mas esta versão do Oracle não suporta ficheiros zipados. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. O ficheiro foi recuperado com sucesso, mas não contem nenhum dado sobre expansões. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database - + XML; spoiler database (*.xml) - + spoiler - + Spoilers import - + Please specify a compatible source for spoiler data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + Tokens import - + Please specify a compatible source for token data. - + Download URL: URL de download: - + Local file: - + Restore default URL URL para repor definições de origem - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Set básico contendo fichas @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Importar Oracle @@ -292,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Erro - + + No set has been imported. Nenhuma expansão foi importada. - + Sets imported Edições importadas - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. Importação terminada: %1 cartas. - + + %1: %2 cards imported %1. %2 cartas importadas - + Save card database Guardar base de dados das cartas - + XML; card database (*.xml) XML; Base de dados de cartas (*.xml) - + The file could not be saved to %1 O ficheiro não pode ser gravado em %1 @@ -414,7 +431,8 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) @@ -587,7 +605,7 @@ i18n - + English Português (Portuguese) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode From 5b895dc55349b849c0fe459be89fc129e1c3af8f Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:38:59 +0200 Subject: [PATCH 061/247] Updates for project Cockatrice and language pt_BR (#7562) * Translate oracle_en@source.ts in pt_BR [Manual Sync] 92% of minimum 5% translated source file: 'oracle_en@source.ts' on 'pt_BR'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in pt_BR [Manual Sync] 52% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'pt_BR'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_pt_BR.ts | 7492 ++++++++++++++----- oracle/translations/oracle_pt_BR.ts | 226 +- 2 files changed, 5660 insertions(+), 2058 deletions(-) diff --git a/cockatrice/translations/cockatrice_pt_BR.ts b/cockatrice/translations/cockatrice_pt_BR.ts index 422ed5a4b..9d81f631c 100644 --- a/cockatrice/translations/cockatrice_pt_BR.ts +++ b/cockatrice/translations/cockatrice_pt_BR.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Definir marcador... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Definir marcador - + New value for counter '%1': Novo valor para marcador '%1': @@ -36,66 +36,115 @@ AbstractTabDeckEditor - + Open in new tab Abrir em uma nova aba - + Are you sure? Você tem certeza? - + The decklist has been modified. Do you want to save the changes? A lista foi modificada. Gostaria de salvar as alterações? - - - - - - + + + + + + Error Erro - + Could not open deck at %1 Não foi possível abrir o deck em %1 - + Could not save remote deck O deck não pode ser salvo remotamente. - - + + The deck could not be saved. Please check that the directory is writable and try again. O deck não pôde ser salvo. Por favor, verifique se a pasta não é somente leitura e tente novamente. - + Save deck Salvar deck - + + + Share deck + Compartilhar Deck + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. O deck não pôde ser salvo. - + There are no cards in your deck to be exported Não há cartas em seu deck a serem exportadas + + AccountSetupPage + + + Join a Server + Conectar ao Servidor + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + Crie uma conta... + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. AdminNotesDialog - + Update Notes Notas de Atualização - + Admin Notes for %1 Notas do Administrador para %1 @@ -132,224 +181,310 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. Tokens - + Fichas AppearanceSettingsPage - + seconds segundos - + Error Erro - + Could not create themes directory at '%1'. Não foi possível criar o diretório de temas em '%1'. - + Theme settings Configurações de Tema - + Current theme: Tema atual: - + Open themes folder Abra a pasta de temas - + Home tab background source: - Fonte do background da tab Home: + Fonte da imagem de fundo da aba Início: - + Home tab background shuffle frequency: - Frequência de escolha aleatória de background da tab Home: + Frequência de escolha aleatória da imagem de fundo da aba Início: - + Disabled Desabilitado - + Menu settings Configurações do Menu - + Show keyboard shortcuts in right-click menus Mostrar atalhos de teclado nos menus de clique com o botão direito - + Show game filter toolbar above list in room tab Mostrar o filtro de jogos acima da lista da tab de sala - + Card rendering Renderização das cartas - + Display card names on cards having a picture Mostrar nome nas cartas que possuem imagem - + Auto-Rotate cards with sideways layout Rotacionar automaticamente cartas com layout horizontal - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Claro - + Dark - + Escuro - + + System + Sistema + + + + Show all playmats - + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: + Paleta de tema ativa: + + + + Active theme style: - + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Editar paleta de tema - + Home tab settings - + Configurações da aba Início - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector Priorizar conjuntos que o deck contém no topo do seletor de impressão - + Scale cards on mouse over Redimensionar cartas ao passar o mouse - + Use rounded card corners Usar cantos de cartas arredondados - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand Porcentagem mínima de sobreposição de cartas na pilha e na mão vertical - + Maximum initial height for card view window: Altura inicial máxima para a janela de visualização de cartas: - - + + rows linhas - + Maximum expanded height for card view window: Altura expandida máxima para a janela de visualização de cartas: - + Card counters Marcadores de carta - + Counter %1 Marcador %1 - + Hand layout Organização da mão - + Display hand horizontally (wastes space) Mostrar a mão na horizontal (desperdiça espaço) - + Enable left justification Habilitada justificação à esquerda - + Table grid layout Organização do campo de batalha - + Invert vertical coordinate Inverter a coordenada vertical - + Minimum player count for multi-column layout: Número mínimo de jogadores para o layout de multi-colunas - + Maximum font size for information displayed on cards: Tamanho máximo da fonte para informações nas cartas: @@ -357,12 +492,12 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Voltar para resultados - + Open Deck in Deck Editor Abrir no Editor de Decks @@ -388,112 +523,112 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. BanDialog - + ban &user name banir &usuário pelo nome - + ban &IP address banir endereço &IP - + ban client I&D banir cliente por I&D: - + Ban type Tipo de banimento - + &permanent ban &banir permanentemente - + &temporary ban &banir temporariamente - + &Days: &Dias: - + &Hours: &Horas: - + &Minutes: &Minutos: - + Duration of the ban Duração do banimento - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Por favor, entre com o motivo do banimento. Isto será visto somente por moderadores e não pode ser visto pela pessoa banida. - + Please enter the reason for the ban that will be visible to the banned person. Por favor, entre com o motivo para o banimento que será visível pela pessoa banida. - + Redact all messages from this user in all rooms Remover todas mensagens deste usuário em todas as salas - + &OK &OK - + &Cancel &Cancelar - + Ban user from server Banir usuário do servidor - - - - + + + + Error Erro - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Você deve selecionar um nome, IP, cliente ou alguma combinação desses três itens para banir. - + You must have a value in the name ban when selecting the name ban checkbox. Você têm que colocar um nome válido quando seleciona a caixa de banimento por nome. - + You must have a value in the ip ban when selecting the ip ban checkbox. Você têm que colocar um IP válido quando seleciona a caixa de banimento por IP. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Você têm que colocar um ID de cliente válido quando seleciona a caixa de banimento por ID de cliente. @@ -501,22 +636,22 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Nenhuma resposta recebida do servidor de atualização. - + Invalid reply received from the release update server. Resposta inválida recebida do servidor de atualização. - + No reply received from the file update server. Nenhuma resposta recebida do servidor de atualização. @@ -524,96 +659,253 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardArtPreviewWidget - - No card selected + + Banner preview + + + + + + Zoom %1× + + + + + No card selected + Nenhuma carta selecionada + CardArtRulesModel - + Card - + Carta - + ProviderId - + Mode - + Modo - + Reason - + Razão CardDatabaseModel - + Name Nome - + Sets Expansões - + Mana cost Custo de mana - + Card type Tipo de carta - + P/T P/R - + Color(s) Cor(es) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Adicionar ao deck - + Add to Sideboard - - - - - Select Printing - + Adicionar ao Sideboard + Select Printing + Selecionar Impressão + + + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -723,22 +1015,22 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardInfoFrameWidget - + Image Imagem - + Description Descrição - + Both Ambos - + View transformation Ver transformação @@ -746,22 +1038,22 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardInfoPictureWidget - + View related cards Visualizar cartas relacionadas - + Add card to deck Adicionar carta ao deck - + Mainboard Main - + Sideboard Side @@ -769,27 +1061,27 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardInfoTextWidget - + Name: Nome: - + Set: Coleção: - + Collector Number: Número de Colecionador: - + Related cards: Cartas relacionadas: - + Unknown card: Carta desconhecida: @@ -797,129 +1089,129 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardMenu - + Re&veal to... Revelar para... - + &All players Todos os &jogadores - + View related cards Visualizar cartas relacionadas - + Token: Ficha: - + All tokens Todas as fichas - + &Select All &Marque todos - + S&elect Row Selecionar Linha - + S&elect Column Selecionar Coluna - + &Play &Jogar - + &Hide &Ocultar - + Play &Face Down Jogue &virada para baixo - + &Tap / Untap Turn sideways or back again &Virar / Desvirar - + Skip &untapping - + T&urn Over Turn face up/face down &Virar - + &Peek at card face &Olhar a face da carta - + &Clone &Clonar - + Attac&h to card... Ane&xar na carta... - + Unattac&h De&sanexar - + &Draw arrow... &Desenhar seta... - + &Set annotation... Colocar &nota... - + Reduce life by power - + Ca&rd counters Marcadores de carta - + &Add counter (%1) &Adicionar marcador(%1) - + &Remove counter (%1) &Remover marcador (%1) - + &Set counters (%1)... &Definir marcadores (%1)... @@ -940,7 +1232,7 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardSizeWidget - + Card Size Tamanho da Carta @@ -1080,6 +1372,24 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid zona customizada '%2' de %1 + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1109,280 +1419,382 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1390,113 +1802,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1546,7 +1958,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database Banco de Dados de Cartas @@ -1562,17 +1974,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Pesquisar pelo nome da carta (ou expressões de busca) - + Add card to &maindeck Adicionar carta ao &baralho principal - + Add card to &sideboard Adicionar carta ao &sideboard @@ -1580,97 +1992,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card Carta em Destaque - + Main Type - + Mana Cost - + Colors Cores - - Select Printing - Escolher Impressão + + Playmat + - + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + + Select Printing + Selecionar Impressão + + + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Deck - + Deck &name: &Nome do Deck: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: &Comments: - + Group by: Agrupar por: - + Format: Formato: - + Hash: Hash: - + &Increment number &Aumentar número - + &Decrement number &Diminuir número - + &Remove row &Remover linha - + Swap card to/from sideboard Mover carta para/do Side @@ -1678,17 +2151,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Filtros - + &Clear all filters &Limpar todos os filtros - + Delete selected Apagar selecionados @@ -1696,114 +2169,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor &Editor de Decks - + &New deck &Novo deck - + &Load deck... &Carregar deck... - + Load recent deck... Carregar deck recente... - + Clear Limpar - + &Save deck &Salvar deck - + Save deck &as... Salvar deck &como... - + + Share deck... + + + + Load deck from cl&ipboard... Carregar deck da área de &transferência... - + Edit deck in clipboard Editar deck na área de transferência - - + + Annotated Anotado - - + + Not Annotated Não anotado - + Save deck to clipboard Salvar deck para a área de transferência - + Annotated (No set info) Anotado (Sem informação de expansão) - + Not Annotated (No set info) Não Anotado (Sem informação de expansão) - + &Print deck... &Imprimir deck... - + Load deck from online service... Carregar baralho de serviço online... - + &Send deck to online service &Enviar deck para serviço online - + Create decklist (decklist.org) Criar decklist (decklist.org) - + Create decklist (decklist.xyz) Criar decklist (decklist.xyz) - + Analyze deck (deckstats.net) &Analisar deck (deckstats.net) - + Analyze deck (tappedout.net) &Analisar deck (tappedout.net) - + &Close &Fechar @@ -1811,7 +2289,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector Seletor de Impressão @@ -1819,110 +2297,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Atualizar Spoilers - + Success Sucesso - + Download URLs have been reset. As URLs de download foram redefinidas. - + Add URL Adicionar URL - - + + URL: URL: - - + + Edit URL Editar URL - + Add New URL Adicionar nova URL - + Remove URL Remover URL - + Updating... Atualizando... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Selecione o caminho - + URL Download Priority Prioridade de download de URL - + Spoilers 'Spoilers' - + Download Spoilers Automatically Baixar 'spoilers' automaticamente - + Spoiler Location: Localização do 'spoiler': - + Last Change Última Alteração - + Spoilers download automatically on launch Baixar automaticamente 'spoilers' ao iniciar - + Press the button to manually update without relaunching Pressione o botão para atualizar manualmente sem reinicializar - + Do not close settings until manual update is complete Não feche as configurações até que a atualização manual termine - + Download card pictures on the fly Baixar imagens de cartas em tempo real - + How to add a custom URL Como adicionar uma URL customizada - + Reset Download URLs Resetar URLs de download @@ -1930,32 +2450,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo Desfazer - + Redo Refazer - + Undo/Redo history Histórico Desfazer/Refazer - + Click on an entry to revert to that point in the history. - + [redo] [refazer] - + [undo] [desfazer] @@ -1963,27 +2483,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Contagem - + Set Conjunto - + Number Número - + Provider ID ID do Provedor - + Card Card @@ -2004,17 +2524,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Modo: Correspondência Exata - + Mode: Includes Modo: Inclui - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2022,7 +2547,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Editar tags ... @@ -2030,62 +2555,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Gerenciador de Tags de Deck - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: Gerencie suas tags de deck. Marque ou desmarque as tags conforme necessário ou adicione novas: - + Add a new tag (e.g., Aggro️) Adicionar uma nova tag (ex.: Aggro) - + Add Tag Adicionar Tag - + Filter tags... Filtrar tags... - + OK OK - + Edit default tags Editar tags padrão - + Cancel Cancelar - + Invalid Input Entrada Inválida - + Tag name cannot be empty! O nome da tag não pode estar vazio! - + Duplicate Tag Tag Duplicada - + This tag already exists. Essa tag já existe. @@ -2093,94 +2618,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card Carta em Destaque - + Open in deck editor Abrir no editor de decks - + + Share deck... + + + + Edit Tags Editar Tags - + Rename Deck Renomear Deck - + Save Deck to Clipboard Salvar deck na área de transferência - + Annotated Anotado - + Annotated (No set info) Anotado (Sem informação de expansão) - + Not Annotated Não Anotado - + Not Annotated (No set info) Não Anotado (Sem informação de expansão) - + Rename File Renomear Arquivo - + Delete File Deletar Arquivo - + Set Banner Card Escolher Carta em Destaque - - + + New name: Novo nome: - - + + Rename deck + + + + + Rename file + + + + + Error Erro - + Rename failed Falha ao renomear - + Delete file Deletar Arquivo - + Are you sure you want to delete the selected file? Tem certeza de que deseja excluir o arquivo selecionado? - + Delete failed Falha ao deletar @@ -2188,57 +2728,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" Renomear baralho de "%1" para "%2" - + Updated comments (was %1 chars, now %2 chars) Comentários atualizados (eram %1 caracteres, agora são %2 caracteres) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed Tags alteradas - + Set format to %1 Definir formato para %1 - + Added (%1): %2 (%3) %4 Adicionado (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Movido para %1 1 × "%2" (%3) - + Removed "%1" (all copies) Removido "%1" (todas as cópias) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Adicionado - + Removed Removido @@ -2260,88 +2845,116 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Carregar deck... - + Load remote deck... Carregar deck remoto... - + Load from clipboard... Carregado da área de transferência... - + Load from website... Carregado do website... - + Unload deck Descarregar deck - + Ready to start Preparadx para começar - + Force start Forçar início - + Sideboard unlocked Sideboard aberto - + Sideboard locked Sideboard fechado - - + + Error Erro - + The selected file could not be loaded. O arquivo selecionado não pode ser carregado - + Deck is greater than maximum file size. O baralho é maior que o tamanho máximo de arquivo. - + Are you sure you want to force start? This will kick all non-ready players from the game. Tem certeza que deseja forçar o início? Isso irá expulsar todos os jogadores não preparados do jogo. - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion Converter formato do deck - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? @@ -2350,7 +2963,7 @@ Tags só podem ser adicionadas a decks no formato .cod. Deseja converter o deck para .cod? - + Remember and automatically apply choice in the future Lembrar e aplicar essa escolha automaticamente no futuro @@ -2363,143 +2976,143 @@ Deseja converter o deck para .cod? Baixando... - + Known Hosts Servidores conhecidos - + Delete the currently selected saved server Excluir o servidor salvo atualmente selecionado - + Refresh the server list with known public servers Atualizar a lista de servidores com servidores públicos conhecidos - + New Host Novo servidor - + Name: Nome: - + &Host: &Servidor: - + &Port: &Porta: - + Player &name: &Nome do jogador: - + P&assword: S&enha: - + &Save password &Salvar senha - + A&uto connect &Conectar automaricamente - + Automatically connect to the most recent login when Cockatrice opens Conectar automaticamente com o login mais recente quando Cockatrice for iniciado - + If you have any trouble connecting or registering then contact the server staff for help! Se você tiver problemas ao se conectar ou registar entre em contato com os responsáveis pelo servidor! - - + + Webpage Página da Web - + Reset Password Resetar Senha - + Forgot password? Esqueceu a senha? - + &Connect &Conectar - + Server Servidor - + Login Login - + Server Contact Salvar Contato - + Connect to Server Conectar ao Servidor - + Server URL URL do Servidor - + Communication Port Porta de comunicação - + Unique Server Name Nome Único de Servidor - + Connection Warning Aviso de conexão - + You need to name your new connection profile. Você deve nomear seu novo perfil de conexão - + Connect Warning Aviso de conexão - + The player name can't be empty. O nome do jogador é obrigatório. @@ -2507,122 +3120,122 @@ Deseja converter o deck para .cod? DlgCreateGame - + Re&member settings Le&mbrar configurações. - + &Description: &Descrição: - + P&layers: &Jogadores: - + General Geral - + Game type Tipo de jogo - + &Password: &Senha: - + Only &buddies can join Apenas &amigos podem entrar - + Only &registered users can join Apenas usuários &registrados podem entrar - + Joining restrictions Restrições para entrar - + &Spectators can watch &Espectadores podem assistir - + Spectators &need a password to watch &Espectadores precisam de uma senha para assistir - + Spectators can &chat Visitantes podem &conversar - + Spectators can see &hands Espectadores podem ver &mãos - + Create game as spectator Criar jogo como espectador - + Spectators Visitantes - + Starting life total: Total de vida inicial: - + Open decklists in lobby Abrir lista de baralhos no lobby - + Create game as judge Criar jogo como juíz - + Game setup options Opções de configuração do jogo - + &Clear &Limpar - + Create game Criar jogo - + Game information Informação de jogo - + Error Erro - + Server error. Erro do servidor. @@ -2630,97 +3243,97 @@ Deseja converter o deck para .cod? DlgCreateToken - + &Name: &Nome: - + Token Ficha - + C&olor: C&or: - + white branco - + blue azul - + black preto - + red vermelho - + green verde - + multicolor multicolorido - + colorless incolor - + &P/T: &P/R: - + &Annotation: &Nota: - + &Destroy token when it leaves the table Destruir a &ficha quando ela sair do campo de batalha - + Create face-down (Only hides name) - + Token data Informação de ficha - + Show &all tokens Mostre &todas fichas - + Show tokens from this &deck Mostre fichas deste &deck - + Choose token from list Mostre fichas da lista - + Create token Criar ficha @@ -2728,53 +3341,53 @@ Deseja converter o deck para .cod? DlgDefaultTagsEditor - + Edit Tags Editar Tags - + Add Adicionar - + Confirm Confirmar - + Cancel Cancelar - + Enter a tag and press Enter Insira a tag e aperte Enter - - + + ✖ ✖ - + Invalid Input Valor Inválido - + Tag name cannot be empty! Nomes de tags não podem ser vazios! - + Duplicate Tag Tag Duplicada - + This tag already exists. Essa tag já existe. @@ -2783,7 +3396,7 @@ Deseja converter o deck para .cod? DlgEditAvatar - + No image chosen. Nenhuma imagem selecionada. @@ -2795,27 +3408,32 @@ To remove your current avatar, confirm without choosing a new image. Para remover o avatar atual, confirme sem escolher uma nova imagem. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Procurar... - + Change avatar Mudar avatar - + Open Image Abrir imagem - + Image Files (*.png *.jpg *.bmp) Arquivos de imagem (*.png *.jpg *.bmp) - + Invalid image chosen. Imagem inválida. @@ -2841,38 +3459,38 @@ Para remover o avatar atual, confirme sem escolher uma nova imagem. DlgEditPassword - + Old password: Senha antiga: - + New password: Nova senha: - + Confirm new password: Confirmar senha: - + Change password Trocar senha: - - + + Error Erro - + Your password is too short. Sua senha é muito curta. - + The new passwords don't match. As novas senhas não coincidem. @@ -2880,93 +3498,93 @@ Para remover o avatar atual, confirme sem escolher uma nova imagem. DlgEditTokens - + &Name: &Nome: - + C&olor: &Cor: - + white branco - + blue azul - + black preto - + red vermelho - + green verde - + multicolor multicor - + colorless incolor - + &P/T: &P/R: - + &Annotation: &Anotação - + Token data Informação da ficha - - + + Add token Adicionar ficha - + Remove token Remover ficha - + Edit custom tokens Editar fichas personalizadas - + Please enter the name of the token: Por favor, entre com o nome da ficha: - + Error Erro - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. O nome escolhido está em conflito com uma carta ou ficha existente. @@ -3045,8 +3663,9 @@ Certifique-se de habilitar a expansão 'Fichas' em "Gerenciar exp - Hide games that have started - Ocultar jogos já iniciados + Hide started games + Hide games that have started + @@ -3081,8 +3700,9 @@ Certifique-se de habilitar a expansão 'Fichas' em "Gerenciar exp - &Creator name: - Nome do &criador: + &Host name: + &Creator name: + @@ -3148,52 +3768,52 @@ Certifique-se de habilitar a expansão 'Fichas' em "Gerenciar exp DlgForgotPasswordChallenge - + Reset Password Challenge Warning Aviso de redefinição de desafio de senha - + A problem has occurred. Please try to request a new password again. Houve um problema. Por favor, tente solicitar uma nova senha novamente. - + Enter the information of the server and the account you'd like to request a new password for. Coloque a informação do servidor e a conta que você gostaria de solicitar uma nova senha. - + &Host: &Servidor: - + &Port: &Porta: - + Player &name: Jogador &nome - + Email: Email: - + Reset Password Challenge Desafio de redefinir senha - + Reset Password Challenge Error Erro no desafio de redefinir senha - + The email address can't be empty. O endereço de email não pode estar vazio. @@ -3201,37 +3821,37 @@ Certifique-se de habilitar a expansão 'Fichas' em "Gerenciar exp DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Coloque a informação do servidor que você gostaria de solicitar uma nova senha. - + &Host: &Servidor: - + &Port: &Porta: - + Player &name: Nome do &jogador - + Reset Password Request Pedido de redefinição de senha. - + Reset Password Error Erro de redefinição de senha - + The player name can't be empty. O nome do jogador não pode ficar vazio. @@ -3239,94 +3859,127 @@ Certifique-se de habilitar a expansão 'Fichas' em "Gerenciar exp DlgForgotPasswordReset - + Reset Password Warning Aviso de Redefinição de Senha - + A problem has occurred. Please try to request a new password again. Um problema ocorreu. Por favor tente requisitar uma nova senha novamente. - + Enter the received token and the new password in order to set your new password. Insira o código recebido e a nova senha para definir sua nova senha. - + &Host: &Servidor: - + &Port: &Porta: - + Player &name: Jogador &nome: - + Token: Ficha: - - + + New Password: Nova senha: - + Reset Password Redefinir Senha - + The player name can't be empty. O nome do jogador não pode estar vazio. - - - - + + + + Reset Password Error Erro de Redefinição de Senha - + The token can't be empty. O código não pode estar vazia. - + The new password can't be empty. A nova senha não pode estar vazia. - + Error Erro - + Your password is too short. Sua senha é muito curta. - + The passwords do not match. As senhas não correspondem. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck Carregar Deck @@ -3410,166 +4063,567 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: Jogadores: - + General Geral - + Starting life total: Vida inicial total: - + Game setup options - + Remember settings Lembrar configurações - + Local game options Opções de jogo local + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Nome da carta (ou expressões de busca): - + Number of hits: Número de resultados: - + Auto play hits Reproduzir resultados automaticamente - + Put top cards on stack until... Colocar as cartas do topo na pilha até... - + No cards matching the search expression exists in the card database. Proceed anyways? Nenhuma carta correspondente à expressão de busca existe no banco de dados de cartas. Deseja continuar assim mesmo? - + Cockatrice Cockatrice - + Invalid filter Filtro inválido + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Insira seus dados e os do servidor que você gostaria de se registrar. Seu e-mail será utilizado para verificar sua conta. - + &Host: &Servidor: - + &Port: &Porta: - + Player &name: Nome do &jogador: - + P&assword: S&enha: - + Password (again): Senha (novamente): - + Email: Email: - + Email (again): Email (novamente): - + Country: País: - + Undefined Indefinido - + Real name: Nome real: - + Register to server Registrar ao servidor: - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Aviso de Registro - + Your password is too short. Sua senha é muito curta. - + Your passwords do not match, please try again. Suas senhas não coincidem, por favor tente novamente. - + Your email addresses do not match, please try again. Seus endereços de email não coincidem, por favor tente novamente. - + The player name can't be empty. O nome de jogador não pode estar vazio. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3635,46 +4689,12 @@ Seu e-mail será utilizado para verificar sua conta. DlgSettings - + Unknown Error loading card database Erro desconhecido ao carregar bando de dados de cartas - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Seu banco de dados de cartas está inválido. - -Cockatrice pode não funcionar corretamente com um banco de dados inválido. - -Você pode precisar rodar o Oracle novamente para atualizar o banco de dados de cartas. - -Você gostaria de mudar o local da configuração de seu banco de dados? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - A versão de seu banco de dados de cartas é muito velho. - -Isto pode causar problemas em carregar informações de cartas ou imagens. - -Usualmente isto pode ser consertado rodando novamente o Oracle para atualizar seu banco de dados de cartas. - -Você gostaria de alterar o local da configuração de seu banco de dados? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3687,7 +4707,7 @@ Por favor, envie um ticket para https://github.com/Cockatrice/Cockatrice/issues Gostaria de mudar a definição da localização da sua base de dados? - + File Error loading your card database. Would you like to change your database location setting? @@ -3696,7 +4716,7 @@ Would you like to change your database location setting? Você gostaria de alterar seu local de configuração de seu banco de dados? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3705,7 +4725,7 @@ Would you like to change your database location setting? Você gostaria de alterar seu local de configuração de banco de dados? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3718,104 +4738,235 @@ Por favor, crie um ticket em https://github.com/Cockatrice/Cockatrice/issues Você gostaria de alterar seu local de configuração do banco de dados? - - - + + + Error Erro - + The path to your deck directory is invalid. Would you like to go back and set the correct path? O caminho para a sua pasta de decks é inválido. Você gostaria de voltar e corrigir o caminho? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? O caminho para a sua pasta de imagens de cards é inválido. Você gostaria de voltar e corrigir o caminho? - + Settings Configurações - + + Search settings... + + + + + OK + + + + General Geral - + Appearance Aparência - + User Interface Interface do usuário - + Storage - + Card Sources Fonte de Cartas - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Conversa - + Sound Som - + Shortcuts Atalhos + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check Checar Atualização de Carta - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time Não executar desta vez @@ -3823,17 +4974,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Próximo - + Previous Anterior - + Tip of the Day Dica do Dia @@ -3841,165 +4992,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel Canal de lançamento atual - + Reinstall Reinstalar - + Cancel Download Cancelar Download - + Open Download Page Abrir página de download - + Check for Client Updates Procurar por atualizações do cliente - - - - + + + + Error Erro - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice não possui suporte a SSL, por causa disso você não pode baixar updates automaticamente! Por favor visite a página de downloads para atualizar manualmente.  - + Downloading update: %1 Baixando atualização: %1 - + Checking for updates... Verificando atualizações... - + Finished checking for updates Busca por atualizações concluída - + No Update Available Sem atualização disponível - + Cockatrice is up to date! Cockatrice está atualizado! - + You are already running the latest version available in the chosen release channel. Você já está executando a última versão disponível no canal de lançamento escolhido. - + Current version Versão atual - + Selected release channel Canal de lançamento selecionado - - + + Update Available Atualização disponível - - + + A new version of Cockatrice is available! Uma nova versão do Cockatrice está disponível! - - + + New version Nova versão - - + + Released Lançado - - + + Changelog Log de mudanças - + Do you want to update now? Você quer atualizar agora? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Infelizmente, o atualizador automática não encontrou um download compatível. Talvez você tenha que baixar a nova versão manualmente. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Por favor confira a <a href="%1">página de releases</a> no nosso repositório do Github e baixe o programa para o seu sistema. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Erro na atualização - + An error occurred while checking for updates: Ocorreu um erro enquanto procurávamos por atualizações: - + An error occurred while downloading an update: Um erro ocorreu enquanto uma atualização era baixada: - + Installing... Instalando... - + Cockatrice is unable to open the installer. Cockatrice está incapaz de abrir o instalador. - + Try to update manually by closing Cockatrice and running the installer. Tente atualizar o Cockatrice manualmente fechando-o e rodando o instalador. - + Download location Localização do download @@ -4007,17 +5168,17 @@ Talvez você tenha que baixar a nova versão manualmente. DlgViewLog - + Clear log when closing Limpar log ao fechar - + Copy to clipboard Copiar para área de transferência - + Debug Log Registro de Depuração @@ -4255,7 +5416,7 @@ Talvez você tenha que baixar a nova versão manualmente. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: Sal: @@ -4263,7 +5424,7 @@ Talvez você tenha que baixar a nova versão manualmente. FilterBuilder - + Type your filter here Digite seu filtro aqui @@ -4271,45 +5432,88 @@ Talvez você tenha que baixar a nova versão manualmente. FilterDisplayWidget - + Confirm Delete Confirmar Exclusão - + Are you sure you want to delete the filter '%1'? - + Delete Failed Falha na Exclusão - + Failed to delete filter '%1'. Falha ao remover filtro '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game jogador saiu do jogo - + player disconnected from server jogador desconectado do servidor - + reason unknown motivo desconhecido @@ -4317,140 +5521,156 @@ Talvez você tenha que baixar a nova versão manualmente. GameSelector - - - - - - - - - + + + + + + + + + Error Erro - + Please join the appropriate room first. Por favor, entre na sala apropriada primeiramente - + Wrong password. Senha incorreta. - + Spectators are not allowed in this game. Não são permitidos visitantes neste jogo. - + The game is already full. O jogo está cheio. - + The game does not exist any more. O jogo não existe mais. - + This game is only open to registered users. Este jogo é aberto apenas para usuários registrados. - + This game is only open to its creator's buddies. Este jogo é aberto apenas para os amigos de quem criou o jogo. - + You are being ignored by the creator of this game. Você está sendo ignorado pelo criador deste jogo. - + Join Game Entrar no jogo - + Spectate Game Assistir ao jogo - + Game Information Informações do jogo - + + Cop&y game link + + + + Join Game as Judge Entrar no Jogo como Juíz - + Spectate Game as Judge - + + Join game Entrar no jogo - - Password: - Senha: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Por favor, entre na respectiva sala primeiro. - + &Filter games &Filtrar jogos. - + C&lear filter L&impar Filtros - + C&reate C&riar - + &Join &Entrar - + Join as judge - + J&oin as spectator E&ntrar como visitante - + Join as judge spectator - + Games shown: %1 / %2 Jogos mostrados: %1 / %2 - + Games Jogos @@ -4491,105 +5711,105 @@ Talvez você tenha que baixar a nova versão manualmente. GamesModel - + >1 day >1 dia - + %1%2 hr short age in hours %1%2 h%1%2 h%1%2 h - + new novo - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password senha - + buddies only apenas amigos - + reg. users only usuários reg. apenas - + open decklists - - + + can chat pode conversar - + see hands ver mãos - + can see hands pode ver mãos - + not allowed não permitidos - + Room Sala - + Age Idade - + Description Descrição - - Creator - Criador + + Host + - + Type Tipo - + Restrictions Restrições - + Players Jogadores - + Spectators Visitantes @@ -4597,158 +5817,275 @@ Talvez você tenha que baixar a nova versão manualmente. GeneralSettingsPage - + Reset all paths Redefinir todos os diretórios - + All paths have been reset Todos os caminhos para diretórios foram redefinidos - - - - - - - + + + + English + Português do Brasil (Brazilian Portuguese) + + + + + + + + + Choose path Escolher caminho - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Idioma: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Caminhos (edição desabilitada no modo portátil) - + Paths Caminhos - + How to help with translations Como ajudar com as traduções - + Decks directory: Pasta de decks: - + Filters directory: - + Replays directory: Pasta de replays: - + Pictures directory: Pasta de imagens: - + Card database: Banco de dados de cartas: - + Custom database directory: Diretório do banco de dados de cartas customizadas - + Token database: Banco de dados de fichas: - + Update channel Canal de atualização - + Check for client updates on startup Verificar atualizações do cliente ao iniciar - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Notificar se uma função suportada pelo servidor está faltando em meu programa. - + Automatically run Oracle when running a new version of Cockatrice Rodar Oracle automaticamente quando rodar uma nova versão de Cockatrice - + Show tips on startup Exibir dicas ao iniciar - + Last update check on %1 (%2 days ago) @@ -4756,47 +6093,47 @@ Talvez você tenha que baixar a nova versão manualmente. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4890,59 +6227,353 @@ Talvez você tenha que baixar a nova versão manualmente. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5160,273 +6791,294 @@ Talvez você tenha que baixar a nova versão manualmente. MainWindow - - + + Player %1 Jogador %1 - + Load replay Carregar replay - + About Cockatrice Sobre o Cockatrice - + Version Versão - + Cockatrice Webpage Pagina da Web do Cockatrice - + Project Manager: Diretor do projeto: - + Past Project Managers: Antigos diretores do projeto: - + Developers: Desenvolvedores: - + Our Developers Nossos desenvolvedores - + Help Develop! Ajude a desenvolver! - + Translators: Tradutores: - + Our Translators Nossos tradutores. - + Help Translate! Ajude a traduzir! - + Support: Suporte: - + Report an Issue Reporte um problema: - + Troubleshooting Solução de problemas - + F.A.Q. F.A.Q. - - + + Error Erro - + &Connect... &Conectar... - + &Disconnect &Desconectar - + Start &local game... Iniciar jogo &local... - + &Watch replay... &Assistir replay... - + &Full screen Tela &cheia - + &Register to server... &Registrar ao servidor: - + &Restore password... &Recuperar senha... - + &Settings... &Configurações... - + &Exit &Sair - + A&ctions Açõ&es - + &Cockatrice &Cockatrice - + C&ard Database Base de Cartas - + &Manage sets... &Gerenciar expansões... - + Edit custom &tokens... Editar &fichas personalizadas - + Open custom image folder Abrir pasta customizada de imagens. - + Open custom sets folder Abrir pasta de sets personalizados - + Add custom sets/cards Adicionar sets/cartas personalizadas - + Reload card database Recarregar banco de dados de cartas - + Tabs Abas - + &Help &Ajuda - + &About Cockatrice So&bre o Cockatrice - + &Tip of the Day &Dica do Dia - + Check for Client Updates Procurar por atualizações do cliente - + Check for Card Updates... Procurar por atualizações de cartas... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log Ver registro de &depuração - + Open Settings Folder Abrir Pasta de Configurações - + + Re-run Onboarding Wizard... + + + + Show/Hide Mostrar/Ocultar - + + Sign-in cancelled + + + + New Version Nova Versão - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Parabéns por atualizar o Cockatrice %1! Oracle será inicializado agora para atualizar sua base de cartas - + Cockatrice installed Cockatrice instalado - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Parabéns pela instalação do Cockatrice %1! O Oracle agora será iniciado para instalar o banco de dados inicial de cartas. - + + Are you sure? + Você tem certeza? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Base de cartas - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5435,29 +7087,29 @@ Gostaria de atualizar seu banco de dados de cartas agora? Se estiver em dúvida ou for um novo usuário selecione "Sim" - - + + Yes Sim - - + + No Não - + Open settings Abrir configurações - + New sets found Novas expansões encontradas - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5470,22 +7122,22 @@ Códigos das coleções: %1 Deseja ativá-las? - + Yes, always enable - + View sets Visualizar expansões - + Welcome Bem vindo - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5494,89 +7146,89 @@ Todas as expansões do banco de dados de cartas foram habilitadas. Leia mais sobre mudar a ordem das expansões ou desabilitando expansões específicas na aba "Gerenciar Expansões". - + Information Informação - + A card database update is already running. Uma atualização da base de dados de cartas já está em andamento. - + Unable to run the card database updater: Não foi possível executar a atualização da base de dados de cartas. - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. Falha ao começar. O arquivo pode não existir ou as permissões estão incorretas. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Carregar expansões/cards - + Selected file cannot be found. Arquivo selecionado não pode ser encontrado - + You can only import XML databases at this time. Você só pode importar bancos de dados XML neste momento. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. As novas cartas/coleções foram adicionadas com sucesso. O Cockatrice reiniciará imediatamente o banco de dados de cartas. - + Sets/cards failed to import. Falha ao importar expansões/cartas. @@ -5781,615 +7433,615 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. MessageLogWidget - + from play do jogo - + from their graveyard do seu cemitério - + from exile vindo do exílio - + from their hand da sua mão - + the top card of %1's library a carta do topo do grimório de %1 - + the top card of their library a carta do topo do seu grimório - + from the top of %1's library do topo do grimório de %1 - + from the top of their library do topo do seu grimório - + the bottom card of %1's library a carta do fundo do grimório de %1 - + the bottom card of their library a carta do fundo do seu grimório - + from the bottom of %1's library do fundo do grimório de %1 - + from the bottom of their library do fundo do seu grimório - + from %1's library do grimório de %1 - + from their library do seu grimório - + from sideboard vindo do sideboard - + from the stack vindo da pilha - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 está mantendo as carta do topo %2 reveladas. - + %1 is not revealing the top card %2 any longer. %1 não está mais revelando as cartas do topo %2. - + %1 can now look at top card %2 at any time. %1 agora pode olhar a carta do topo de %2 a qualquer momento. - + %1 no longer can look at top card %2 at any time. %1 não pode mais olhar a carta do topo de %2 a qualquer momento. - + %1 attaches %2 to %3's %4. %1 vincula %2 a %4 de %3. - + %1 has conceded the game. %1 concedeu o jogo. - + %1 has unconceded the game. %1 desfez a concessão do jogo - + %1 has restored connection to the game. %1 restaurou a conexão com o jogo. - + %1 has lost connection to the game. %1 perdeu conexão com o jogo. - + %1 points from their %2 to themselves. %1 aponta de sua %2 para si - + %1 points from their %2 to %3. %1 aponta de sua %2 para %3. - + %1 points from %2's %3 to themselves. %1 aponta da %3 de %2 para si - + %1 points from %2's %3 to %4. %1 aponta da %3 de %2 para %4 - + %1 points from their %2 to their %3. %1 aponta da sua %2 para sua %3 - + %1 points from their %2 to %3's %4. %1 aponta de sua %2 para %4 de %3 - + %1 points from %2's %3 to their own %4. %1 aponta da %3 de %2 para seu próprio %4. - + %1 points from %2's %3 to %4's %5. %1 aponta da %3 de %2 para a %5 de %4. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 cria uma ficha: %2%3. - + %1 has loaded a deck (%2). %1 carregou um deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 carregou um deck com %2 cartas no sideboard (%3). - + %1 destroys %2. %1 destrói %2. - + a card um card - + %1 gives %2 control over %3. %1 dá controle para %2 sobre %3. - + %1 puts %2 into play%3 face down. %1 coloca %2 em jogo%3 com a face voltada para baixo. - + %1 puts %2 into play%3. %1 põe %2 no campo de batalha %3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 coloca %2%3 em seu cemitério. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exila %2%3. - + %1 moves %2%3 to their hand. %1 moveu %2%3 para sua mão. - + %1 puts %2%3 into their library. %1 coloca %2%3 em seu grimório - + %1 puts %2%3 onto the bottom of their library. %1 coloca %2%3 no fundo de seu grimório. - + %1 puts %2%3 on top of their library. %1 coloca %2%3 no topo de seu grimório. - + %1 puts %2%3 into their library %4 cards from the top. %1 coloca %2%3 do seu grimório %4 cartas abaixo do topo. - + %1 moves %2%3 to sideboard. %1 move %2%3 para o sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 põe %2 na pilha%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 tenta comprar de uma biblioteca vazia - + %1 draws %2 card(s). %1 comprou %2 carta(s).%1 comprou %2 carta(s).%1 comprou %2 carta(s). - + %1 is looking at %2. %1 está olhando para %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 está olhando para a(s) %3 carta(s) %4 %2.%1 está olhando para a(s) %3 carta(s) %4 %2.%1 está olhando para a(s) %3 carta(s) %4 %2. - + bottom de fundo - + top de topo - + %1 turns %2 face-down. %1 turnos %2 cartas viradas para baixo. - + %1 turns %2 face-up. %1 turnos %2 cartas viradas para cima. - + The game has been closed. O jogo foi fechado. - + The game has started. O jogo começou. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 entrou no jogo. - + %1 is now watching the game. %1 está assistindo o jogo agora. - + You have been kicked out of the game. Você foi 'chutado' do jogo. - + %1 has left the game (%2). %1 deixou o jogo (%2) - + %1 is not watching the game any more (%2). %1 não está mais assistindo o jogo (%2). - + %1 is not ready to start the game any more. %1 não está mais pronto para inciar o jogo. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 embaralhou seu grimório e comprou uma nova mão com %2 card.%1 embaralhou seu grimório e comprou uma nova mão com %2 cards.%1 embaralhou seu grimório e comprou uma nova mão com %2 cards. - + %1 shuffles their deck and draws a new hand. %1 embaralhou seu grimório e comprou uma nova mão. - + You are watching a replay of game #%1. Você está assistindo o replay do jogo #%1. - + %1 is ready to start the game. %1 está pronto para inciar o jogo. - + cards an unknown amount of cards cartas - + %1 card(s) a card for singular, %1 cards for plural %1 carta%1 carta(s)%1 carta(s) - + %1 lends %2 to %3. %1 empresta %2 para %3. - + %1 reveals %2 to %3. %1 revela %2 para %3. - + %1 reveals %2. %1 revela %2. - + %1 randomly reveals %2%3 to %4. %1 revela aleatoriamente %2%3 para %4. - + %1 randomly reveals %2%3. %1 revela aleatoriamente %2%3. - + %1 peeks at face down card #%2. %1 olha a carta virada para baixo #%2. - + %1 peeks at face down card #%2: %3. %1 espia a face para baixo da carta #%2: %3. - + %1 reveals %2%3 to %4. %1 revela %2 %3 para %4. - + %1 reveals %2%3. %1 revela %2%3. - + %1 reversed turn order, now it's %2. %1 reverteu a ordem dos turnos, agora é a vez de %2. - + reversed revertido - + normal normal - + Heads Cara - + Tails Coroa - + %1 flipped a coin. It landed as %2. %1 jogou uma moeda. Saiu %2. - + %1 rolls a %2 with a %3-sided die. %1 tirou %2 em um D%3. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 joga %2 moedas. São %3 caras e %4 coroas. - + %1 rolls a %2-sided dice %3 times: %4. %1 rola um dado de %2 lados %3 vezes: %4. - + %1's turn. Turno de %1. - + %1 sets annotation of %2 to %3. %1 altera a anotação de %2 para %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 ajusta o marcador %2 para %3 (%4%5). - + %1 sets %2 to not untap normally. %1 determina que %2 não desvira normalmente. - + %1 sets %2 to untap normally. %1 determina que %2 desvira normalmente. - + %1 removes the PT of %2. %1 remove o P/R de %2 - + %1 changes the PT of %2 from nothing to %4. %1 alterar o PR de %2 de nada para %4. - + %1 changes the PT of %2 from %3 to %4. %1 alterar o P/R de %2 de %3 para %4. - + %1 has locked their sideboard. %1 bloqueou seu sideboard - + %1 has unlocked their sideboard. %1 desbloqueou seu sideboard - + %1 taps their permanents. %1 vira suas permanentes. - + %1 untaps their permanents. %1 desvira suas permanentes. - + %1 taps %2. %1 vira %2 - + %1 untaps %2. %1 desvira %2. - + %1 shuffles %2. %1 embaralha %2 - + %1 shuffles the bottom %3 cards of %2. %1 embaralha as %3 cartas do fundo de %2 - + %1 shuffles the top %3 cards of %2. %1 embaralha as %3 cartas do topo de %2 - + %1 shuffles cards %3 - %4 of %2. %1 embaralha as cartas %3 - %4 de %2 - + %1 unattaches %2. %1 desanexa %2. - + %1 undoes their last draw. %1 desfaz sua última compra. - + %1 undoes their last draw (%2). %1 desfaz sua última compra (%2). @@ -6397,115 +8049,125 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. MessagesSettingsPage - + Word1 Word2 Word3 Palavra1 Palavra2 Palavra3 - + Add New Message Adicionar nova mensagem - + Edit Message Editar mensagem - + Remove Message Remover mensagem - + Add message Adicionar mensagem - - + + Message: Mensagem: - + Edit message Editar mensagem - + Chat settings Configurações de Conversa - + Custom alert words Palavras de alertas customizadas. - + Enable chat mentions Habilitar menções em conversa - + Enable mention completer Habilitar completador de menções. - + In-game message macros Mensagem no jogo - + How to use in-game message macros Como usar macros de mensagens no jogo - + Ignore chat room messages sent by unregistered users Ignorar mensagens na sala enviadas por usuários não registrados - + Ignore private messages sent by unregistered users Ignorar mensagens privadas enviadas por usuários não registrados - + Ignore private messages sent by non-buddy users - - + + Invert text color Inverter cor do texto - + Enable desktop notifications for private messages Ativar notificações da área de trabalho para mensagens privadas. - + Enable desktop notification for mentions Habilitar notificações de desktop para menções. - + Enable room message history on join Ativar histórico de mensagens da sala ao entrar - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Cor em hexadecimal) - + Separate words with a space, alphanumeric characters only Separar palavras com um espaço, apenas caracteres alfanuméricos @@ -6513,42 +8175,42 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6627,148 +8289,134 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6891,30 +8539,21 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. Etapa de fim de combate - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - pt - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7033,16 +8672,272 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7063,12 +8958,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7076,25 +8971,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference Preferência - + Pin Printing Fixar Impressão - + Unpin Printing Desafixar Impressão - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards Mostrar Cartas Relacionadas + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7125,28 +9060,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical Alfabética - + Preference Preferência - + Release Date Data de Lançamento - - + + Descending Decrescente - + Ascending Crescente @@ -7162,95 +9097,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Serviços - + Hide %1 Esconder %1 - + Hide Others Esconder outros - + Show All Mostrar tudo - + Preferences... Preferências - + Quit %1 Sair %1 - + About %1 Sobre %1 @@ -7258,17 +9201,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Banco de dados de cartas do Cockatrice (*.xml) - + All files (*.*) Todos arquivos (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7288,17 +9231,22 @@ This setting means you'll only see the default printing for each card, instead o Fichas - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7309,7 +9257,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7320,100 +9268,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Salvar - + Save All Salvar Todos - + Open Abrir - + &Yes &Sim - + Yes to &All Sim para &Todos - + &No &Não - + N&o to All Não para &Todos - + Abort Abortar - + Retry Tentar Novamente - + Ignore Ignorar - + Close Fechar - + Cancel Cancelar - + Discard Descartar - + Help Ajuda - + Apply Aplicar - + Reset Resetar - + Restore Defaults Restaurar Padrão @@ -7421,121 +9379,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Nome - + ID ID - + Upload time Hora do upload + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID Código - + Name Nome - + Players Jogadores - + Keep Manter - + Time started Tempo iniciado - + Duration (sec) Duração (seg) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7577,37 +9603,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Salas - + Joi&n &Entrar - + Room Sala - + Description Descrição - + Permissions Permissões - + Players Jogadores - + Games Jogos @@ -7615,7 +9641,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7623,32 +9649,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Escolha uma ação da tabela - + Hit the key/combination of keys you want to set for this action Pressione a tecla/combinação de teclas que deseja definir para esta ação - + Invalid key Tecla inválida - + Shortcut already in use by: Atalho já está sendo usado por: - + Clear Limpar - + Restore default Restaurar padrão @@ -7681,56 +9707,92 @@ Fine-tune individual colours in the grid afterwards. Data lançamento + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Restaurar todos atalhos padrões - + Do you really want to restore all default shortcuts? Você tem certeza que deseja restaurar todos atalhos padrões? - + Clear all default shortcuts Limpar todos os atalhos padrão - + Do you really want to clear all shortcuts? Você realmente quer limpar todos os atalhos? - + Section: Seção: - + Action: Ação: - + Shortcut: Atalho: - + + Shortcut editor + + + + How to set custom shortcuts Como definir atalhos personalizados - + Clear all shortcuts Limpar todos os atalhos - + Search by shortcut name Pesquisar pelo nome do atalho @@ -7786,12 +9848,12 @@ Por favor, verifique suas configurações de atalho! SideboardMenu - + &Sideboard - + &View sideboard @@ -7799,27 +9861,27 @@ Por favor, verifique suas configurações de atalho! SoundSettingsPage - + Enable &sounds Habilitar &sons - + Current sounds theme: Tema sonoro atual: - + Test system sound engine Testar sistema de som - + Sound settings Configurações de som - + Master volume Volume principal @@ -7827,48 +9889,48 @@ Por favor, verifique suas configurações de atalho! SpoilerBackgroundUpdater - + Spoilers season has ended Temporada de 'spoilers' encerrada - + Deleting spoiler.xml. Please run Oracle Deletando spoiler.xml. Favor rodar o Oracle - - + + Spoilers download failed Falha no download de 'spoilers' - + No internet connection Sem conexão com a internet - + Error Erro - + Spoilers already up to date 'Spoilers' já atualizados - + No new spoilers added Nenhum 'spoiler' novo adicionado - + Spoilers have been updated! 'Spoilers' foram adicionados! - + Last change: Última alteração: @@ -7876,157 +9938,170 @@ Por favor, verifique suas configurações de atalho! StableReleaseChannel - + Default Padrão - + No reply received from the release update server. Sem resposta do servidor de atualização - + Invalid reply received from the release update server. Resposta inválida do servidor de atualização - + No reply received from the tag update server. Nenhuma resposta recebida do servidor de atualização de tags. - + Invalid reply received from the tag update server. Resposta inválida recebida do servidor de atualização de tags. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8034,17 +10109,33 @@ Por favor, verifique suas configurações de atalho! TabAccount - + + + My Reports + + + + Add to Buddy List Adicionar à lista de amigos - + Add to Ignore List Adicionar à lista de ignorados - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account Conta @@ -8165,118 +10256,118 @@ Por favor, verifique suas configurações de atalho! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8289,42 +10380,42 @@ Por favor, verifique suas configurações de atalho! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8337,52 +10428,52 @@ Por favor, verifique suas configurações de atalho! TabDeckEditor - + Card Info Informação da carta. - + Deck Deck - + Filters Filtros - + &View &Visualizar - + Card Database - + Printing Impressão - + Visible Visível - + Floating Flutuante - + Reset layout Reiniciar layout - + Deck: %1 Deck: %1 @@ -8390,49 +10481,49 @@ Por favor, verifique suas configurações de atalho! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8440,22 +10531,22 @@ Por favor, verifique suas configurações de atalho! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8463,139 +10554,237 @@ Por favor, verifique suas configurações de atalho! TabDeckStorage - + Local file system Sistema de arquivos local - + Server deck storage Armazenamento de decks no servidor - - + + Open in deck editor Abrir no editor de decks - + Rename deck or folder - + Upload deck Upload do deck - + Download deck Download do deck - - - - + + + + New folder Nova pasta - - + + Delete Apagar - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Abrir pasta de decks - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error Error - + Rename failed - - + + + Invalid deck file Arquivo de deck inválido - + Enter deck name Entre o nome do deck - + This decklist does not have a name. Please enter a name: Este deck não tem um nome. Por favor, entre um nome: - + Unnamed deck Deck sem nome - + Failed to upload deck to server Falha ao enviar deck ao servidor - + Delete local file Excluir arquivo local - + Are you sure you want to delete the selected files? Tem certeza de que deseja excluir os arquivos selecionados? - + Delete remote decks Excluir decks remotos - + Are you sure you want to delete the selected decks? Tem certeza de que deseja excluir os decks selecionados? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Nome da nova pasta: - + Deck Storage Armazenamento de Deck @@ -8603,20 +10792,271 @@ Por favor, entre um nome: TabDeckStorageVisual - + Visual Deck Storage Decks Visuais - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8629,27 +11069,27 @@ Por favor, entre um nome: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8662,191 +11102,207 @@ Por favor, entre um nome: TabGame - - - + + + Replay Replay - - + + Game Jogo - - + + Player List Lista de jogadores. - - + + + Invite + + + + + Card Info Informação da carta - - + + Messages Mensagens - - + + Replay Timeline Linha do tempo do Replay - + &Phases &Etapas - + &Game &Jogo - + Next &phase Próxima &etapa - + Next phase with &action Próxima etapa com &acao - + Next &turn Próximo &turno - + Reverse turn order Ordem dos turnos revertida - + &Remove all local arrows &Apagar todas as setas locais - + Rotate View Cl&ockwise Girar visualização em sentido h&orário - + Rotate View Co&unterclockwise Girar visualização em sentido a&nti-horário - + Game &information &Informação de jogo - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede Cancelar Rendição - - - + + + &Concede &Conceder - + &Leave game &Sair do jogo - + C&lose replay &Fechar replay - + &Focus Chat &Focar no chat - + &Say: &Falar: - + Selected cards - + &View &Visualizar - + Visible Visível - + Floating Flutuante - + Reset layout Reiniciar layout - + Concede Conceder - + Are you sure you want to concede this game? Você tem certeza que deseja conceder este jogo? - + Unconcede Defazer concessão - + You have already conceded. Do you want to return to this game? Você já concedeu. Quer retornar a esse jogo? - + Leave game Sair do jogo - + Are you sure you want to leave this game? Você tem certeza que deseja sair deste jogo? - + A player has joined game #%1 Um jogador entrou no jogo #%1 - + %1 has joined the game %1 entrou no jogo - + You have been kicked out of the game. Você foi 'chutado' do jogo. @@ -8856,164 +11312,164 @@ Por favor, entre um nome: Home - + Início TabLog - + Logs Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Tempo;RemetenteNome;RemetenteIP;Mensagem;AlvoID;AlvoNome - + Room Logs Logs de salas - + Game Logs Logs de jogos - + Chat Logs Logs de conversas - - + + Error Erro - + You must select at least one filter. Você precisa selecionar pelo menos um filtro. - + You have to select a valid number of days to locate. Você precisa selecionar um número de dias válido para localizar - + Username: Nome de usuário. - + IP Address: Endereço de IP: - + Game Name: Nome do jogo: - + GameID: ID do Jogo: - + Message: Mensagem: - + Main Room Sala Principal - + Game Room Sala de Jogo - + Private Chat Chat Privado. - + Past X Days: X dias anteriores: - + Today Hoje - + Last Hour Última hora - + Maximum Results: Máximo de resultados: - + At least one filter is required. The more information you put in, the more specific your results will be. Ao menos um filtro deve ser selecionado. Quanto mais informação você inserir, mais específicos seus resultados serão. - + Get User Logs Obter logs de usuário - + Clear Filters Limpar Filtros - + Filters Filtros - + Log Locations Logs de localização - + Date Range Intervalo de data - + Maximum Results Máximo de resultados - - + + Message History Histórico de Mensagens - + Failed to collect message history information. Falha ao coletar informação do histórico de mensagens. - + There are no messages for the selected filters. Não existe mensagem que corresponda aos filtros selecionados. @@ -9021,218 +11477,558 @@ Quanto mais informação você inserir, mais específicos seus resultados serão TabMessage - + Private &chat Conversa privada - + &Leave &Sair - + %1 - Private chat %1 - Conversa privada - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Este usuário esta ignorando você, ele não pode ver suas mensagens no chat principal e você não pode entrar nos jogos dele. - + Private message from Mensagem privada de - + %1 has left the server. %1 saiu do servidor. - + %1 has joined the server. %1 entrou no servidor. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Sistema de arquivos local - + Server replay storage Servidor de armazenamento de replays - - + + Watch replay Assistir replay - + Rename Renomear - - + + New folder Nova pasta - - + + Delete Excluir - + Open replays folder Abrir pasta de repetições - + Download replay Baixar replay - + Toggle expiration lock Trocar expiração da trava - + Get replay share code - - + + Look up replay by share code - + Rename local folder Renomear pasta local - + Rename local file Renomear arquivo local - + New name: Novo nome: - + Error Erro - + Rename failed Falha ao renomear - + Name of new folder: Nome da nova pasta: - + Delete local file Excluir arquivo local - + Are you sure you want to delete the selected files? Tem certeza de que deseja excluir os arquivos selecionados? - + Are you sure you want to delete the selected replays? Tem certeza de que deseja excluir as repetições selecionadas? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Excluir replay remoto @@ -9242,65 +12038,478 @@ Quanto mais informação você inserir, mais específicos seus resultados serão Repetições de jogo + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Falar: - + Chat Chat - + &Room &Sala - + &Leave room S&air da sala - + &Clear chat Limpar conversa - + Chat Settings... Configurações de Conversa - + mentioned you. mencionou você. - + Click to view Clique para ver - + You are flooding the chat. Please wait a couple of seconds. Você está flodando o chat. Por favor, espere alguns segundos. @@ -9308,35 +12517,35 @@ Quanto mais informação você inserir, mais específicos seus resultados serão TabServer - + Server Servidor - - - - + + + + Error Erro - + Failed to join the server room: it doesn't exist on the server. Falha ao entrar na sala: ela não existe no servidor. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. O servidor pensa que você está na sala, mas seu cliente não consegue exibi-la. Tente reiniciar o seu cliente. - + You do not have the required permission to join this server room. Você não tem permissão para entrar nesta sala. - + Failed to join the server room due to an unknown error: %1. Falha ao entrar na sala devido a um erro desconhecido: %1. @@ -9344,97 +12553,173 @@ Quanto mais informação você inserir, mais específicos seus resultados serão TabSupervisor - + Deck Editor Editor de Deck - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + Início - + &Visual Deck Storage &Decks Visuais - + Visual Database Display - + Server Servidor - + Account Conta - + Deck Storage Armazenamento de Deck - + Game Replays Repetições de jogo - + Administration Administração - + Logs Registros - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Você tem certeza? - + There are still open games. Are you sure you want to quit? Ainda existem jogos abertos. Você tem certeza que deseja sair? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Clique para visualizar - + Your buddy %1 has signed on! Seu amigo %1 entrou no servidor! - + Unknown Event Evento desconhecido. - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9445,39 +12730,39 @@ Esta mensagem pode significar que existe uma nova versão do Cockatrice disponí Para atualizar seu cliente, vá para Ajuda -> Verificar atualizações. - + Idle Timeout Tempo esgotado de inatividade - + You are about to be logged out due to inactivity. Você irá ser desconectado por inatividade - + Promotion Promoção - + You have been promoted. Please log out and back in for changes to take effect. Você foi promovido a moderador(a). Por favor faça logout e depois acesse novamente para que as mudanças sejam efetivadas. - + Warned Advertido - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Você recebeu uma advertência por %1. Por favor evite tais ações ou outras medidas poderão ser tomadas contra você. Se você tem qualquer dúvida, por favor envie uma mensagem privada um dos moderadores. - + You have received the following message from the server. (custom messages like these could be untranslated) Você recebeu a seguinte mensagem do servidor. @@ -9497,6 +12782,34 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9511,6 +12824,65 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você Não foi possível analisar o deck. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9582,57 +12954,52 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9640,216 +13007,266 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você UserContextMenu - + User &details &Detalhes do usuário - + Private &chat Conversa privada - + Show this user's &games Mostrar este usuário de jogo - + + View this user's &public decks + + + + Add to &buddy list Adicionar à &lista de amigos - + Remove from &buddy list Remover da li&sta de amigos - + Add to &ignore list Adicionar à li&sta dos ignorados - + Remove from &ignore list Remover da lista dos i&gnorados - + Kick from &game C&hutar do jogo - + + Report user + + + + Warn user Advertir usuário - + View user's war&n history Visualizar o histórico de advertê&ncias do usuário. - + Ban from &server Ban&ir do servidor - + View user's &ban history Visualizar o histórico de %banimento do usuário. - + &Promote user to moderator &Promover usuário a moderador - + Dem&ote user from moderator Rev&ogar as permissões de moderação do usuário - + Promote user to &judge Promover usuário a &juiz - + Demote user from judge Remover usuário da posição de juiz - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes Visualizar notas do administrador - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games Jogos de %1 - - - + + + Ban History Histórico de banimento - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Tempo de banimento;Moderador;Duração do Banimento;Razão do Banimento;Razão Visível - + User has never been banned. O usuário nunca foi banido. - + Failed to collect ban information. Falha ao coletar informações de banidas. - - - + + + Warning History Histórico de advertências - + Warning Time;Moderator;User Name;Reason Tempo de advertência;Moderador;Nome de Usuário;Razão - + User has never been warned. Usuário nunca foi advertido. - + Failed to collect warning information. Falha ao coletar informações de aviso. - + Failed to get admin notes. Falha ao obter as notas do administrador. - - + + Success Sucesso - + Successfully promoted user. Usuário promovido com sucesso. - + Successfully demoted user. Permissões de moderação revogadas com sucesso. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Falha - + Failed to promote user. Falha ao promover usuário. - + Failed to demote user. Falha ao revogar permissões de moderação do usuário. - + Copy hash to clipboard Copiar hash para área de transferência - + Remove this user's messages Remover mensagens deste usuário @@ -9887,61 +13304,61 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você Administrador - + Moderator Moderador - + Registered user Usuário registrado - - + + Unregistered user Usuário não registrado - + Judge Juíz - + Unknown Desconhecido - + The entered password does not match your account. A senha inserida não coincide com de sua conta. - - - - + + + + Information Informação - + User information updated. Informações do usuário atualizadas - - - - - - + + + + + - - - + + + + Error Erro @@ -9965,283 +13382,292 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n ano(s),%n ano(s),%n ano(s), - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n dia(s) %20%10%n dia(s) %20%10%n dia(s) %20 - + Enter Password Insira a senha - + Password verification is required in order to change your email address Uma verificação de senha é obrigatória para alterar seu endereço de e-mail - - - + + + An error occurred while trying to update your user information. Ocorreu um erro ao tentar atualizar suas informações de usuário. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Este servidor não permite que você atualize suas informações de usuário. - + Password changed. Senha alterada. - + This server does not permit you to change your password. Este servidor não permite que você altere sua senha. - + The new password is too short. A nova senha é muito curta. - + The old password is incorrect. A senha antiga está incorreta. - + Avatar updated. Avatar alterado. - + This server does not permit you to update your avatar. Este servidor não permite que você altere seu avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Este servidor não permite que você atualize suas informações de usuário. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10249,223 +13675,344 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você UserInterfaceSettingsPage - + General interface settings Configurações gerais de interface - + &Double-click cards to play them (instead of single-click) &Duplo clique nos cards para jogá-los (ao invés de clique simples) - + &Clicking plays all selected cards (instead of just the clicked card) Clicar reproduz todas as cartas selecionadas (em vez de apenas a carta clicada) - + &Play all nonlands onto the stack (not the battlefield) by default &Jogar todos não terrenos na pilha (não campo de batalha) por padrão - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed Fechar janela de visualização ao remover a última carta - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Anotar texto de card nas fichas - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Usar menus tear-off, permitindo que menus de clique com botão direito persistam na tela - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Configurações de Notificações - + Enable notifications in taskbar Habilitar notificações na barra de tarefas - + Notify in the taskbar for game events while you are spectating Notificar na barra de tarefas para eventos do jogo enquanto você está como espectador - + Notify in the taskbar when users in your buddy list connect Notificar na barra de tarefas quando usuários na sua lista de amigos se conectar - + Animation settings Configurações de animação - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation Animação de &virar/desvirar - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings Configurações do editor/armazenamento de decks - + Open deck in new tab by default Abrir deck em uma nova aba por padrão - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby Usar decks visuais no lobby do jogo - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Configurações de repetição - + Buffer time for backwards skip via shortcut: Tempo de buffer para retroceder usando atalho: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 Usuários conectados ao servidor: %1 - + Users in this room: %1 Usuários nesta sala: %1 - + Buddies online: %1 / %2 Amigos online: %1 / %2 - + Ignored users online: %1 / %2 Usuários ignorados online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10502,22 +14049,22 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10525,72 +14072,72 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10692,7 +14239,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10700,19 +14247,19 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10758,28 +14305,28 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10787,43 +14334,43 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10839,12 +14386,12 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10852,17 +14399,17 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10870,7 +14417,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckStorageFolderDisplayWidget - + Deck Storage Decks Visuais @@ -10878,52 +14425,57 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10931,7 +14483,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10939,22 +14491,22 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) Ordenar Alfabeticamente (Nome do Deck) - + Sort Alphabetically (Filename) Ordenar Alfabeticamente (Nome do Arquivo) - + Sort by Last Modified Ordenar por Última Modificação - + Sort by Last Loaded Ordenar por Último Carregamento @@ -10962,17 +14514,22 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckStorageWidget - + Loading database ... Carregando banco de dados ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10980,177 +14537,202 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você WarningDialog - + Which warning would you like to send? Qual advertência você gostaria de enviar? - + Redact all messages from this user in all rooms Remover todas mensagens deste usuário em todas salas - + &OK %OK - + &Cancel &Cancelar - + Warn user for misconduct Advertir usuario por mau comportamento. - - + + Error Erro - + User name to send a warning to can not be blank, please specify a user to warn. O campo "nome de usuário" ao qual se deseja enviar uma advertência não pode permanecer em branco, por favor especifique um usuário para ser advertido. - + Warning to use can not be blank, please select a valid warning to send. O campo "advertência" não pode permanecer em branco, por favor selecione uma advertência para ser enviada. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Mover o selecionado para o topo - + Move selected set up Mover o selecionado para cima - + Move selected set down Mover o selecionado para baixo - + Move selected set to the bottom Mover o selecionado para o fundo - - Search by set name, code, or type - Procurar por expansão, código ou tipo - - - + Default order Ordem padrão - + Restore original art priority order Restaurar prioridade da ordem de arte original - + Enable all sets Habilitar todas expansões - + Disable all sets Desabilitar todas expansões - + Enable selected set(s) Habilitar expansão/expansões selecionada(s) - + Disable selected set(s) Desabilitar expansão/expansões selecionada(s) - + Deck Editor Editor de Deck - + Use CTRL+A to select all sets in the view. Use CTRL+A para selecionar todas expansões visíveis. - + Only cards in enabled sets will appear in the card list of the deck editor. Apenas cartas em expansões ativadas irão aparecer na lista de cartas do editor de deck. - + Image priority is decided in the following order: Prioridade da imagem é decidida na seguinte ordem: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki primeiro a pasta de PERSONALIDADAS (%1), depois as expansões habilitadas neste diálogo (de cima para baixo) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Arte da Carta - + + Search by set name, code, type, or release date + + + + How to use custom card art Como usar arte de carta personalizada - + Hints Dicas - + Note Nota - + Sorting by column allows you to find a set while not changing set priority. Classificar por coluna te permite encontrar um set sem mudar a prioridade de sets - + To enable ordering again, click the column header until this message disappears. Para reabilitar a ordenação, clique no cabeçalho da coluna até que esta mensagem desapareça - + Use the current sorting as the set priority instead Usar a classificação atual como a prioridade de sets - + Sorts the set priority using the same column Classificar a prioridade de sets usando a mesma coluna - + Manage sets Gerenciar expansões @@ -11158,93 +14740,85 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped Não agrupado - + Group by Type Agrupar por Tipo - + Group by Mana Value Agrupar por Valor de Mana - + Group by Color Agrupar por Cor - + Unsorted Não ordenado - + Sort by Name Ordenar por Nome - + Sort by Type Ordenar por Tipo - + Sort by Mana Cost Ordenar por Custo de Mana - + Sort by Colors Ordenar por Cores - + Sort by P/T Ordenar por P/T - + Sort by Set Ordenar por Conjunto - + shuffle when closing embaralhar quando fechar - + pile view visualização da pilha - - i18n - - - English - Português do Brasil (Brazilian Portuguese) - - main - + Connect on startup Conectar ao iniciar - + Debug to file Depurar para um arquivo @@ -12028,6 +15602,16 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você Graveyard Cemitério + + + Report Queue + + + + + Moderation + + @@ -12295,7 +15879,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você Home - + Início diff --git a/oracle/translations/oracle_pt_BR.ts b/oracle/translations/oracle_pt_BR.ts index 30c4e5e65..fc0f5e1ed 100644 --- a/oracle/translations/oracle_pt_BR.ts +++ b/oracle/translations/oracle_pt_BR.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Introdução - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Esse recurso irá importar a lista de coleções, cartas e fichas que serão utilizadas pelo Cokatrice. - + Interface language: Idioma da interface: - + Version: Versão: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Selecione a origem - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Por favor especifique uma fonte compatível de expansões e cartas. Você pode especificar um endereço URL que será baixado ou usar um arquivo existente no seu computador. - + Download URL: Endereço para download: - + Local file: Arquivo local: - + Restore default URL Restaurar URL padrão - + Choose file... Escolha o arquivo... - + Load sets file Carregar arquivos de expansão - + Sets file (%1) Sets JSON file (%1) Arquivo de expansões (%1) - - - - - - - + + + + + Error Erro - + The provided URL is not valid. A URL escolhida não é valida - - Downloading (0MB) - Baixando (0MB) - - - + Please choose a file. Por favor, escolha um arquivo; - + Cannot open file '%1'. Não pode abrir arquivo '%1' - - Downloading (%1MB) - Baixando (%1MB) - - - + Network error: %1. Erro na conexão com a internet: %1. - + Parsing file Analisando arquivo - + Xz extraction failed. Extração de arquivo .xz falhou - + Sorry, this version of Oracle does not support xz compressed files. Desculpe, esta versão do Oracle não suporta arquivos comprimidos no formato .xz - + Failed to open Zip archive: %1. Falhou em abrir o arquivo Zip: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. A extração do arquivo ZIP falhou: o arquivo ZIP não contém exatamente um arquivo. - + Zip extraction failed: %1. Extração do arquivo Zip falhou: %1. - + Sorry, this version of Oracle does not support zipped files. Desculpe, esta versão do Oracle não suporta arquivos compactados. - + Failed to interpret downloaded data. Falha ao interpretar dados baixados - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Gostaria de baixar os arquivos descomprimidos, em vez disso? - + + The file was retrieved successfully, but it does not contain any sets data. O arquivo foi recebido com sucesso, mas não contém qualquer informação de expansão. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Salvar base de dados de spoiler - + XML; spoiler database (*.xml) XML; base de dados de spoilers (*.xml) - + spoiler - + spoiler - + Spoilers import Importar spoilers - + Please specify a compatible source for spoiler data. Por favor, selecione uma fonte compatível para os dados de spoilers. - + Download URL: Endereço para download: - + Local file: - + Arquivo local: - + Restore default URL Restaurar URL padrão - + Choose file... - + Escolha o arquivo... - + The spoiler database will be saved at the following location: A base de dados de spoilers será salva no seguinte diretório: - + Save to a custom path (not recommended) Salvar em diretório customizado (não recomendado) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Salvar base de dados de tokens - + XML; token database (*.xml) XML; base de dados de token (*.xml) - + tokens - + fichas - + Tokens import Importar tokens - + Please specify a compatible source for token data. Por favor especifique uma fonte compatível para os dados de tokens. - + Download URL: Endereço para download: - + Local file: - + Arquivo local: - + Restore default URL Restaurar URL padrão - + Choose file... - + Escolha o arquivo... - + The token database will be saved at the following location: A base de dados de tokens será salva no seguinte diretório: - + Save to a custom path (not recommended) Salvar em diretório customizado (não recomendado) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Esta expansão contém fichas. @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Importador Oracle @@ -292,22 +306,22 @@ OutroPage - + Finished Finalizado - + The wizard has finished. O recurso terminou. - + You can now start using Cockatrice with the newly updated cards. Agora você pode começar a usar o Cockatrice com as novas cartas atualizadas. - + If the card databases don't reload automatically, restart the Cockatrice client. Se a base de dados das cartas não for atualizada automaticamente, reinicie o cliente do Cockatrice. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Erro - + + No set has been imported. O set não foi importado. - + Sets imported Expansões importadas - - A cockatrice database file of %1 MB has been downloaded. - Um arquivo de %1 MB de banco de dados do cockatrice foi baixado. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: As seguintes expansões foram encontradas: - + Press "Save" to store the imported cards in the Cockatrice database. Aperte "Salvar" para guardar as cartas importadas na base de dados do Cockatrice. - + The card database will be saved at the following location: A base de dados de cartas será salva no seguinte diretório: - + Save to a custom path (not recommended) Salvar em diretório customizado (não recomendado) - + &Save &Salvar - + + Import finished: %1 cards. Importação finalizada: %1 cartas. - + + %1: %2 cards imported %1: %2 cartas importadas - + Save card database Carta salva no banco de dados - + XML; card database (*.xml) XML; banco de dados de cartas (*.xml) - + The file could not be saved to %1 O arquivo não pode ser salvo para %1 @@ -391,12 +408,12 @@ Load %1 file - + Carregar arquivo de %1 %1 file (%1) - + Arquivo de %1 (%1) @@ -410,22 +427,23 @@ The provided URL is not valid: - + O endereço web fornecido não é válido: - Downloading (0MB) - Baixando (0MB) + Downloading (0 MB) + Downloading (0MB) + Please choose a file. - + Por favor, escolha um arquivo. Cannot open file '%1'. - + Não pôde abrir o arquivo '%1'. @@ -587,7 +605,7 @@ i18n - + English Português do Brasil (Brazilian Portuguese) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Rodar apenas em modo de 'spoiler' - + Run in no-confirm background mode From 47133bcc6b59824552e0b05b749808c415efc64f Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:40:23 +0200 Subject: [PATCH 062/247] Updates for project Cockatrice and language en_US (#7568) * Translate oracle_en@source.ts in en_US [Manual Sync] 93% of minimum 5% translated source file: 'oracle_en@source.ts' on 'en_US'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in en_US [Manual Sync] 67% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'en_US'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_en_US.ts | 7454 ++++++++++++++----- oracle/translations/oracle_en_US.ts | 204 +- 2 files changed, 5630 insertions(+), 2028 deletions(-) diff --git a/cockatrice/translations/cockatrice_en_US.ts b/cockatrice/translations/cockatrice_en_US.ts index 00075c8c3..ba1bab28f 100644 --- a/cockatrice/translations/cockatrice_en_US.ts +++ b/cockatrice/translations/cockatrice_en_US.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Set counter... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Set counter - + New value for counter '%1': New value for counter '%1': @@ -36,66 +36,115 @@ AbstractTabDeckEditor - + Open in new tab Open in new tab - + Are you sure? Are you sure? - + The decklist has been modified. Do you want to save the changes? The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error Error - + Could not open deck at %1 Could not open deck at %1 - + Could not save remote deck Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. The deck could not be saved. Please check that the directory is writable and try again. - + Save deck Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. The deck could not be saved. - + There are no cards in your deck to be exported There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes Update Notes - + Admin Notes for %1 Admin Notes for %1 @@ -138,218 +187,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds seconds - + Error Error - + Could not create themes directory at '%1'. Could not create themes directory at '%1'. - + Theme settings Theme settings - + Current theme: Current theme: - + Open themes folder Open themes folder - + Home tab background source: Home tab background source: - + Home tab background shuffle frequency: Home tab background shuffle frequency: - + Disabled Disabled - + Menu settings Menu settings - + Show keyboard shortcuts in right-click menus Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab Show game filter toolbar above list in room tab - + Card rendering Card rendering - + Display card names on cards having a picture Display card names on cards having a picture - + Auto-Rotate cards with sideways layout Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Scale cards on mouse over - + Use rounded card corners Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: Maximum initial height for card view window: - - + + rows rows - + Maximum expanded height for card view window: Maximum expanded height for card view window: - + Card counters Card counters - + Counter %1 Counter %1 - + Hand layout Hand layout - + Display hand horizontally (wastes space) Display hand horizontally (wastes space) - + Enable left justification Enable left justification - + Table grid layout Table grid layout - + Invert vertical coordinate Invert vertical coordinate - + Minimum player count for multi-column layout: Minimum player count for multi-column layout: - + Maximum font size for information displayed on cards: Maximum font size for information displayed on cards: @@ -357,12 +492,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results Back to results - + Open Deck in Deck Editor Open Deck in Deck Editor @@ -388,112 +523,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name ban &user name - + ban &IP address ban &IP address - + ban client I&D ban client I&D - + Ban type Ban type - + &permanent ban &permanent ban - + &temporary ban &temporary ban - + &Days: &Days: - + &Hours: &Hours: - + &Minutes: &Minutes: - + Duration of the ban Duration of the ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. - + Please enter the reason for the ban that will be visible to the banned person. Please enter the reason for the ban that will be visible to the banned person. - + Redact all messages from this user in all rooms Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancel - + Ban user from server Ban user from server - - - - + + + + Error Error - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -501,22 +636,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. No reply received from the release update server. - + Invalid reply received from the release update server. Invalid reply received from the release update server. - + No reply received from the file update server. No reply received from the file update server. @@ -524,7 +659,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -532,22 +680,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -555,65 +703,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name Name - + Sets Sets - + Mana cost Mana cost - + Card type Card type - + P/T P/T - + Color(s) Color(s) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -723,22 +1015,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image Image - + Description Description - + Both Both - + View transformation View transformation @@ -746,22 +1038,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards View related cards - + Add card to deck Add card to deck - + Mainboard Mainboard - + Sideboard Sideboard @@ -769,27 +1061,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: Name: - + Set: Set: - + Collector Number: Collector Number: - + Related cards: Related cards: - + Unknown card: Unknown card: @@ -797,129 +1089,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... Re&veal to... - + &All players &All players - + View related cards View related cards - + Token: Token: - + All tokens All tokens - + &Select All &Select All - + S&elect Row S&elect Row - + S&elect Column S&elect Column - + &Play &Play - + &Hide &Hide - + Play &Face Down Play &Face Down - + &Tap / Untap Turn sideways or back again &Tap / Untap - + Skip &untapping Skip &untapping - + T&urn Over Turn face up/face down T&urn Over - + &Peek at card face &Peek at card face - + &Clone &Clone - + Attac&h to card... Attac&h to card... - + Unattac&h Unattac&h - + &Draw arrow... &Draw arrow... - + &Set annotation... &Set annotation... - + Reduce life by power - + Ca&rd counters Ca&rd counters - + &Add counter (%1) &Add counter (%1) - + &Remove counter (%1) &Remove counter (%1) - + &Set counters (%1)... &Set counters (%1)... @@ -940,7 +1232,7 @@ This is only saved for moderators and cannot be seen by the banned person. CardSizeWidget - + Card Size Card Size @@ -1080,6 +1372,24 @@ This is only saved for moderators and cannot be seen by the banned person.%1's custom zone '%2' + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1109,280 +1419,382 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1390,113 +1802,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1546,7 +1958,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database Card Database @@ -1562,17 +1974,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Search by card name (or search expressions) - + Add card to &maindeck Add card to &maindeck - + Add card to &sideboard Add card to &sideboard @@ -1580,97 +1992,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... Loading Database... - + Banner Card Banner Card - + Main Type Main Type - + Mana Cost Mana Cost - + Colors Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Deck - + Deck &name: Deck &name: - + Banner Card/Tags Visibility Settings Banner Card/Tags Visibility Settings - + Show banner card selection menu Show banner card selection menu - + Show tags selection menu Show tags selection menu - + &Comments: &Comments: - + Group by: Group by: - + Format: Format: - + Hash: Hash: - + &Increment number &Increment number - + &Decrement number &Decrement number - + &Remove row &Remove row - + Swap card to/from sideboard Swap card to/from sideboard @@ -1678,17 +2151,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Filters - + &Clear all filters &Clear all filters - + Delete selected Delete selected @@ -1696,114 +2169,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor &Deck Editor - + &New deck &New deck - + &Load deck... &Load deck... - + Load recent deck... Load recent deck... - + Clear Clear - + &Save deck &Save deck - + Save deck &as... Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... Load deck from cl&ipboard... - + Edit deck in clipboard Edit deck in clipboard - - + + Annotated Annotated - - + + Not Annotated Not Annotated - + Save deck to clipboard Save deck to clipboard - + Annotated (No set info) Annotated (No set info) - + Not Annotated (No set info) Not Annotated (No set info) - + &Print deck... &Print deck... - + Load deck from online service... Load deck from online service... - + &Send deck to online service &Send deck to online service - + Create decklist (decklist.org) Create decklist (decklist.org) - + Create decklist (decklist.xyz) Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) Analyze deck (tappedout.net) - + &Close &Close @@ -1811,7 +2289,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector Printing Selector @@ -1819,110 +2297,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Update Spoilers - + Success Success - + Download URLs have been reset. Download URLs have been reset. - + Add URL Add URL - - + + URL: URL: - - + + Edit URL Edit URL - + Add New URL Add New URL - + Remove URL Remove URL - + Updating... Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Choose path - + URL Download Priority URL Download Priority - + Spoilers Spoilers - + Download Spoilers Automatically Download Spoilers Automatically - + Spoiler Location: Spoiler Location: - + Last Change Last Change - + Spoilers download automatically on launch Spoilers download automatically on launch - + Press the button to manually update without relaunching Press the button to manually update without relaunching - + Do not close settings until manual update is complete Do not close settings until manual update is complete - + Download card pictures on the fly Download card pictures on the fly - + How to add a custom URL How to add a custom URL - + Reset Download URLs Reset Download URLs @@ -1930,32 +2450,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo Undo - + Redo Redo - + Undo/Redo history Undo/Redo history - + Click on an entry to revert to that point in the history. Click on an entry to revert to that point in the history. - + [redo] [redo] - + [undo] [undo] @@ -1963,27 +2483,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Count - + Set Set - + Number Number - + Provider ID Provider ID - + Card Card @@ -2004,17 +2524,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Mode: Exact Match - + Mode: Includes Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2022,7 +2547,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Edit tags ... @@ -2030,62 +2555,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) Add a new tag (e.g., Aggro️) - + Add Tag Add Tag - + Filter tags... Filter tags... - + OK OK - + Edit default tags Edit default tags - + Cancel Cancel - + Invalid Input Invalid Input - + Tag name cannot be empty! Tag name cannot be empty! - + Duplicate Tag Duplicate Tag - + This tag already exists. This tag already exists. @@ -2093,94 +2618,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card Banner Card - + Open in deck editor Open in deck editor - + + Share deck... + + + + Edit Tags Edit Tags - + Rename Deck Rename Deck - + Save Deck to Clipboard Save Deck to Clipboard - + Annotated Annotated - + Annotated (No set info) Annotated (No set info) - + Not Annotated Not Annotated - + Not Annotated (No set info) Not Annotated (No set info) - + Rename File Rename File - + Delete File Delete File - + Set Banner Card Set Banner Card - - + + New name: New name: - - + + Rename deck + + + + + Rename file + + + + + Error Error - + Rename failed Rename failed - + Delete file Delete file - + Are you sure you want to delete the selected file? Are you sure you want to delete the selected file? - + Delete failed Delete failed @@ -2188,57 +2728,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed Tags changed - + Set format to %1 Set format to %1 - + Added (%1): %2 (%3) %4 Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Added - + Removed Removed @@ -2260,88 +2845,116 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Load deck... - + Load remote deck... Load remote deck... - + Load from clipboard... Load from clipboard... - + Load from website... Load from website... - + Unload deck Unload deck - + Ready to start Ready to start - + Force start Force start - + Sideboard unlocked Sideboard unlocked - + Sideboard locked Sideboard locked - - + + Error Error - + The selected file could not be loaded. The selected file could not be loaded. - + Deck is greater than maximum file size. Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? @@ -2350,7 +2963,7 @@ This will kick all non-ready players from the game. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future Remember and automatically apply choice in the future @@ -2363,143 +2976,143 @@ This will kick all non-ready players from the game. Downloading... - + Known Hosts Known Hosts - + Delete the currently selected saved server Delete the currently selected saved server - + Refresh the server list with known public servers Refresh the server list with known public servers - + New Host New Host - + Name: Name: - + &Host: &Host: - + &Port: &Port: - + Player &name: Player &name: - + P&assword: P&assword: - + &Save password &Save password - + A&uto connect A&uto connect - + Automatically connect to the most recent login when Cockatrice opens Automatically connect to the most recent login when Cockatrice opens - + If you have any trouble connecting or registering then contact the server staff for help! If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage Webpage - + Reset Password Reset Password - + Forgot password? Forgot password? - + &Connect &Connect - + Server Server - + Login Login - + Server Contact Server Contact - + Connect to Server Connect to Server - + Server URL Server URL - + Communication Port Communication Port - + Unique Server Name Unique Server Name - + Connection Warning Connection Warning - + You need to name your new connection profile. You need to name your new connection profile. - + Connect Warning Connect Warning - + The player name can't be empty. The player name can't be empty. @@ -2507,122 +3120,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Re&member settings - + &Description: &Description: - + P&layers: P&layers: - + General General - + Game type Game type - + &Password: &Password: - + Only &buddies can join Only &buddies can join - + Only &registered users can join Only &registered users can join - + Joining restrictions Joining restrictions - + &Spectators can watch &Spectators can watch - + Spectators &need a password to watch Spectators &need a password to watch - + Spectators can &chat Spectators can &chat - + Spectators can see &hands Spectators can see &hands - + Create game as spectator Create game as spectator - + Spectators Spectators - + Starting life total: Starting life total: - + Open decklists in lobby Open decklists in lobby - + Create game as judge Create game as judge - + Game setup options Game setup options - + &Clear &Clear - + Create game Create game - + Game information Game information - + Error Error - + Server error. Server error. @@ -2630,97 +3243,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Name: - + Token Token - + C&olor: C&olor: - + white white - + blue blue - + black black - + red red - + green green - + multicolor multicolor - + colorless colorless - + &P/T: &P/T: - + &Annotation: &Annotation: - + &Destroy token when it leaves the table &Destroy token when it leaves the table - + Create face-down (Only hides name) Create face-down (Only hides name) - + Token data Token data - + Show &all tokens Show &all tokens - + Show tokens from this &deck Show tokens from this &deck - + Choose token from list Choose token from list - + Create token Create token @@ -2728,53 +3341,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags Edit Tags - + Add Add - + Confirm Confirm - + Cancel Cancel - + Enter a tag and press Enter Enter a tag and press Enter - - + + ✖ ✖ - + Invalid Input Invalid Input - + Tag name cannot be empty! Tag name cannot be empty! - + Duplicate Tag Duplicate Tag - + This tag already exists. This tag already exists. @@ -2783,7 +3396,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. No image chosen. @@ -2795,27 +3408,32 @@ To remove your current avatar, confirm without choosing a new image. To remove your current avatar, confirm without choosing a new image. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Browse... - + Change avatar Change avatar - + Open Image Open Image - + Image Files (*.png *.jpg *.bmp) Image Files (*.png *.jpg *.bmp) - + Invalid image chosen. Invalid image chosen. @@ -2841,38 +3459,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: Old password: - + New password: New password: - + Confirm new password: Confirm new password: - + Change password Change password - - + + Error Error - + Your password is too short. Your password is too short. - + The new passwords don't match. The new passwords don't match. @@ -2880,93 +3498,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: &Name: - + C&olor: C&olor: - + white white - + blue blue - + black black - + red red - + green green - + multicolor multicolor - + colorless colorless - + &P/T: &P/T: - + &Annotation: &Annotation: - + Token data Token data - - + + Add token Add token - + Remove token Remove token - + Edit custom tokens Edit custom tokens - + Please enter the name of the token: Please enter the name of the token: - + Error Error - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. The chosen name conflicts with an existing card or token. @@ -3045,8 +3663,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started - Hide games that have started + Hide started games + Hide games that have started + @@ -3081,8 +3700,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - &Creator name: + &Host name: + &Creator name: + @@ -3148,52 +3768,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. Enter the information of the server and the account you'd like to request a new password for. - + &Host: &Host: - + &Port: &Port: - + Player &name: Player &name: - + Email: Email: - + Reset Password Challenge Reset Password Challenge - + Reset Password Challenge Error Reset Password Challenge Error - + The email address can't be empty. The email address can't be empty. @@ -3201,37 +3821,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Enter the information of the server you'd like to request a new password for. - + &Host: &Host: - + &Port: &Port: - + Player &name: Player &name: - + Reset Password Request Reset Password Request - + Reset Password Error Reset Password Error - + The player name can't be empty. The player name can't be empty. @@ -3239,94 +3859,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning Reset Password Warning - + A problem has occurred. Please try to request a new password again. A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. Enter the received token and the new password in order to set your new password. - + &Host: &Host: - + &Port: &Port: - + Player &name: Player &name: - + Token: Token: - - + + New Password: New Password: - + Reset Password Reset Password - + The player name can't be empty. The player name can't be empty. - - - - + + + + Reset Password Error Reset Password Error - + The token can't be empty. The token can't be empty. - + The new password can't be empty. The new password can't be empty. - + Error Error - + Your password is too short. Your password is too short. - + The passwords do not match. The passwords do not match. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck Load Deck @@ -3418,166 +4071,567 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: Players: - + General General - + Starting life total: Starting life total: - + Game setup options Game setup options - + Remember settings Remember settings - + Local game options Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Card name (or search expressions): - + Number of hits: Number of hits: - + Auto play hits Auto play hits - + Put top cards on stack until... Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice Cockatrice - + Invalid filter Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: &Host: - + &Port: &Port: - + Player &name: Player &name: - + P&assword: P&assword: - + Password (again): Password (again): - + Email: Email: - + Email (again): Email (again): - + Country: Country: - + Undefined Undefined - + Real name: Real name: - + Register to server Register to server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Registration Warning - + Your password is too short. Your password is too short. - + Your passwords do not match, please try again. Your passwords do not match, please try again. - + Your email addresses do not match, please try again. Your email addresses do not match, please try again. - + The player name can't be empty. The player name can't be empty. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3643,46 +4697,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Unknown Error loading card database - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3695,7 +4715,7 @@ Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with you Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3704,7 +4724,7 @@ Would you like to change your database location setting? Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3713,7 +4733,7 @@ Would you like to change your database location setting? Would you like to change your database location setting? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3726,77 +4746,208 @@ Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues Would you like to change your database location setting? - - - + + + Error Error - + The path to your deck directory is invalid. Would you like to go back and set the correct path? The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings Settings - + + Search settings... + + + + + OK + + + + General General - + Appearance Appearance - + User Interface User Interface - + Storage - + Card Sources Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Sound - + Shortcuts Shortcuts + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. @@ -3805,27 +4956,27 @@ Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground Run in foreground - + Run in background Run in background - + Run in background and always from now on Run in background and always from now on - + Don't prompt again and don't run Don't prompt again and don't run - + Don't run this time Don't run this time @@ -3833,17 +4984,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Next - + Previous Previous - + Tip of the Day Tip of the Day @@ -3851,166 +5002,176 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel Current release channel - + Reinstall Reinstall - + Cancel Download Cancel Download - + Open Download Page Open Download Page - + Check for Client Updates Check for Client Updates - - - - + + + + Error Error - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 Downloading update: %1 - + Checking for updates... Checking for updates... - + Finished checking for updates Finished checking for updates - + No Update Available No Update Available - + Cockatrice is up to date! Cockatrice is up to date! - + You are already running the latest version available in the chosen release channel. You are already running the latest version available in the chosen release channel. - + Current version Current version - + Selected release channel Selected release channel - - + + Update Available Update Available - - + + A new version of Cockatrice is available! A new version of Cockatrice is available! - - + + New version New version - - + + Released Released - - + + Changelog Changelog - + Do you want to update now? Do you want to update now? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Update Error - + An error occurred while checking for updates: An error occurred while checking for updates: - + An error occurred while downloading an update: An error occurred while downloading an update: - + Installing... Installing... - + Cockatrice is unable to open the installer. Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. Try to update manually by closing Cockatrice and running the installer. - + Download location Download location @@ -4018,17 +5179,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing Clear log when closing - + Copy to clipboard Copy to clipboard - + Debug Log Debug Log @@ -4266,7 +5427,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: Salt: @@ -4274,7 +5435,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Type your filter here @@ -4282,45 +5443,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete Confirm Delete - + Are you sure you want to delete the filter '%1'? Are you sure you want to delete the filter '%1'? - + Delete Failed Delete Failed - + Failed to delete filter '%1'. Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator kicked by game host or moderator - + player left the game player left the game - + player disconnected from server player disconnected from server - + reason unknown reason unknown @@ -4328,140 +5532,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Error - + Please join the appropriate room first. Please join the appropriate room first. - + Wrong password. Wrong password. - + Spectators are not allowed in this game. Spectators are not allowed in this game. - + The game is already full. The game is already full. - + The game does not exist any more. The game does not exist any more. - + This game is only open to registered users. This game is only open to registered users. - + This game is only open to its creator's buddies. This game is only open to its creator's buddies. - + You are being ignored by the creator of this game. You are being ignored by the creator of this game. - + Join Game Join Game - + Spectate Game Spectate Game - + Game Information Game Information - + + Cop&y game link + + + + Join Game as Judge Join Game as Judge - + Spectate Game as Judge Spectate Game as Judge - + + Join game Join game - - Password: - Password: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Please join the respective room first. - + &Filter games &Filter games - + C&lear filter C&lear filter - + C&reate C&reate - + &Join &Join - + Join as judge Join as judge - + J&oin as spectator J&oin as spectator - + Join as judge spectator Join as judge spectator - + Games shown: %1 / %2 Games shown: %1 / %2 - + Games Games @@ -4502,105 +5722,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 day - + %1%2 hr short age in hours %1%2 hr%1%2 hrs - + new new - + %1%2 min short age in minutes %1%2 min%1%2 mins - + password password - + buddies only buddies only - + reg. users only reg. users only - + open decklists open decklists - - + + can chat can chat - + see hands see hands - + can see hands can see hands - + not allowed not allowed - + Room Room - + Age Age - + Description Description - - Creator - Creator + + Host + - + Type Type - + Restrictions Restrictions - + Players Players - + Spectators Spectators @@ -4608,158 +5828,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Reset all paths - + All paths have been reset All paths have been reset - - - - - - - + + + + English + English + + + + + + + + + Choose path Choose path - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Language: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Paths (editing disabled in portable mode) - + Paths Paths - + How to help with translations How to help with translations - + Decks directory: Decks directory: - + Filters directory: Filters directory: - + Replays directory: Replays directory: - + Pictures directory: Pictures directory: - + Card database: Card database: - + Custom database directory: Custom database directory: - + Token database: Token database: - + Update channel Update channel - + Check for client updates on startup Check for client updates on startup - + Check for card database updates on startup Check for card database updates on startup - + Don't check Don't check - + Prompt for update Prompt for update - + Always update in the background Always update in the background - + Check for card database updates every Check for card database updates every - + days days - + Notify if a feature supported by the server is missing in my client Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup Show tips on startup - + Last update check on %1 (%2 days ago) Last update check on %1 (%2 days ago) @@ -4767,47 +6104,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard &Graveyard - + &View graveyard &View graveyard - + &Move graveyard to... &Move graveyard to... - + &Top of library &Top of library - + &Bottom of library &Bottom of library - + &All players &All players - + &Hand &Hand - + &Exile &Exile - + Reveal random card to... Reveal random card to... @@ -4901,59 +6238,353 @@ You may have to manually download the new version. Reveal r&andom card to... + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck Create New Deck - + Browse Decks Browse Decks - + Browse Card Database Browse Card Database - + Browse EDHRec Browse EDHRec - + Browse Archidekt Browse Archidekt - + View Replays View Replays - + Quit Quit - + Connecting... Connecting... - + Connect Connect - + Play Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5171,273 +6802,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Player %1 - + Load replay Load replay - + About Cockatrice About Cockatrice - + Version Version - + Cockatrice Webpage Cockatrice Webpage - + Project Manager: Project Manager: - + Past Project Managers: Past Project Managers: - + Developers: Developers: - + Our Developers Our Developers - + Help Develop! Help Develop! - + Translators: Translators: - + Our Translators Our Translators - + Help Translate! Help Translate! - + Support: Support: - + Report an Issue Report an Issue - + Troubleshooting Troubleshooting - + F.A.Q. F.A.Q. - - + + Error Error - + &Connect... &Connect... - + &Disconnect &Disconnect - + Start &local game... Start &local game... - + &Watch replay... &Watch replay... - + &Full screen &Full screen - + &Register to server... &Register to server... - + &Restore password... &Restore password... - + &Settings... &Settings... - + &Exit &Exit - + A&ctions A&ctions - + &Cockatrice &Cockatrice - + C&ard Database C&ard Database - + &Manage sets... &Manage sets... - + Edit custom &tokens... Edit custom &tokens... - + Open custom image folder Open custom image folder - + Open custom sets folder Open custom sets folder - + Add custom sets/cards Add custom sets/cards - + Reload card database Reload card database - + Tabs Tabs - + &Help &Help - + &About Cockatrice &About Cockatrice - + &Tip of the Day &Tip of the Day - + Check for Client Updates Check for Client Updates - + Check for Card Updates... Check for Card Updates... - + Check for Card Updates (Automatic) Check for Card Updates (Automatic) - + Show Status Bar Show Status Bar - + View &Debug Log View &Debug Log - + Open Settings Folder Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide Show/Hide - + + Sign-in cancelled + + + + New Version New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5446,29 +7098,29 @@ Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes Yes - - + + No No - + Open settings Open settings - + New sets found New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5479,22 +7131,22 @@ Set codes: %1 Do you want to enable them? - + Yes, always enable - + View sets View sets - + Welcome Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5503,90 +7155,90 @@ All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information Information - + A card database update is already running. A card database update is already running. - + Unable to run the card database updater: Unable to run the card database updater: - + Card database update running. Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. Unknown error occurred. - + The card database updater exited with an error: %1 The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Load sets/cards - + Selected file cannot be found. Selected file cannot be found. - + You can only import XML databases at this time. You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. Sets/cards failed to import. @@ -5791,615 +7443,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play from play - + from their graveyard from their graveyard - + from exile from exile - + from their hand from their hand - + the top card of %1's library the top card of %1's library - + the top card of their library the top card of their library - + from the top of %1's library from the top of %1's library - + from the top of their library from the top of their library - + the bottom card of %1's library the bottom card of %1's library - + the bottom card of their library the bottom card of their library - + from the bottom of %1's library from the bottom of %1's library - + from the bottom of their library from the bottom of their library - + from %1's library from %1's library - + from their library from their library - + from sideboard from sideboard - + from the stack from the stack - + from custom zone '%1' from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 attaches %2 to %3's %4. - + %1 has conceded the game. %1 has conceded the game. - + %1 has unconceded the game. %1 has unconceded the game. - + %1 has restored connection to the game. %1 has restored connection to the game. - + %1 has lost connection to the game. %1 has lost connection to the game. - + %1 points from their %2 to themselves. %1 points from their %2 to themselves. - + %1 points from their %2 to %3. %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. %1 creates a face down token. - + %1 creates token: %2%3. %1 creates token: %2%3. - + %1 has loaded a deck (%2). %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. %1 destroys %2. - + a card a card - + %1 gives %2 control over %3. %1 gives %2 control over %3. - + %1 puts %2 into play%3 face down. %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 puts %2 into play%3. - + %1 puts %2%3 into their graveyard face down. %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exiles %2%3. - + %1 moves %2%3 to their hand. %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. %1 moves %2%3 to sideboard. - + %1 plays %2%3 face down. %1 plays %2%3 face down. - + %1 plays %2%3. %1 plays %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 tries to draw from an empty library - + %1 draws %2 card(s). %1 draws %2 card.%1 draws %2 cards. - + %1 is looking at %2. %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 is looking at the %4 %3 card %2.%1 is looking at the %4 %3 cards %2. - + bottom bottom - + top top - + %1 turns %2 face-down. %1 turns %2 face-down. - + %1 turns %2 face-up. %1 turns %2 face-up. - + The game has been closed. The game has been closed. - + The game has started. The game has started. - + You are flooding the game. Please wait a couple of seconds. You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 has joined the game. - + %1 is now watching the game. %1 is now watching the game. - + You have been kicked out of the game. You have been kicked out of the game. - + %1 has left the game (%2). %1 has left the game (%2). - + %1 is not watching the game any more (%2). %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 shuffles their deck and draws a card.%1 shuffles their deck and draws a new hand of %2 cards. - + %1 shuffles their deck and draws a new hand. %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. You are watching a replay of game #%1. - + %1 is ready to start the game. %1 is ready to start the game. - + cards an unknown amount of cards cards - + %1 card(s) a card for singular, %1 cards for plural one card%1 cards - + %1 lends %2 to %3. %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 reveals %2 to %3. - + %1 reveals %2. %1 reveals %2. - + %1 randomly reveals %2%3 to %4. %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. %1 reveals %2%3 to %4. - + %1 reveals %2%3. %1 reveals %2%3. - + %1 reversed turn order, now it's %2. %1 reversed turn order, now it's %2. - + reversed reversed - + normal normal - + Heads Heads - + Tails Tails - + %1 flipped a coin. It landed as %2. %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1's turn. - + %1 sets annotation of %2 to %3. %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. %1 sets %2 to untap normally. - + %1 removes the PT of %2. %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 has locked their sideboard. - + %1 has unlocked their sideboard. %1 has unlocked their sideboard. - + %1 taps their permanents. %1 taps their permanents. - + %1 untaps their permanents. %1 untaps their permanents. - + %1 taps %2. %1 taps %2. - + %1 untaps %2. %1 untaps %2. - + %1 shuffles %2. %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 unattaches %2. - + %1 undoes their last draw. %1 undoes their last draw. - + %1 undoes their last draw (%2). %1 undoes their last draw (%2). @@ -6407,115 +8059,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 Word1 Word2 Word3 - + Add New Message Add New Message - + Edit Message Edit Message - + Remove Message Remove Message - + Add message Add message - - + + Message: Message: - + Edit message Edit message - + Chat settings Chat settings - + Custom alert words Custom alert words - + Enable chat mentions Enable chat mentions - + Enable mention completer Enable mention completer - + In-game message macros In-game message macros - + How to use in-game message macros How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users Ignore private messages sent by unregistered users - + Ignore private messages sent by non-buddy users - - + + Invert text color Invert text color - + Enable desktop notifications for private messages Enable desktop notifications for private messages - + Enable desktop notification for mentions Enable desktop notification for mentions - + Enable room message history on join Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only Separate words with a space, alphanumeric characters only @@ -6523,42 +8185,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to Move to - + &Top of library in random order &Top of library in random order - + X cards from the top of library... X cards from the top of library... - + &Bottom of library in random order &Bottom of library in random order - + T&able T&able - + &Hand &Hand - + &Graveyard &Graveyard - + &Exile &Exile @@ -6637,148 +8299,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6901,30 +8549,21 @@ Cockatrice will now reload the card database. End of turn step - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - en - - PlayerActions - - - - + + + + grave grave - - - - + + + + exile exile @@ -7043,16 +8682,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" Player "%1" - + &Counters &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7077,12 +8972,12 @@ This setting means you'll only see the default printing for each card, inst PrintingSelector - + Display Navigation Buttons Display Navigation Buttons - + Printing Selector Printing Selector @@ -7090,25 +8985,65 @@ This setting means you'll only see the default printing for each card, inst PrintingSelectorCardOverlayWidget - + Preference Preference - + Pin Printing Pin Printing - + Unpin Printing Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7139,28 +9074,28 @@ This setting means you'll only see the default printing for each card, inst PrintingSelectorCardSortingWidget - + Alphabetical Alphabetical - + Preference Preference - + Release Date Release Date - - + + Descending Descending - + Ascending Ascending @@ -7176,95 +9111,103 @@ This setting means you'll only see the default printing for each card, inst PtMenu - + Power / toughness Power / toughness - + &Increase power &Increase power - + &Decrease power &Decrease power - + I&ncrease toughness I&ncrease toughness - + D&ecrease toughness D&ecrease toughness - + In&crease power and toughness In&crease power and toughness - + Dec&rease power and toughness Dec&rease power and toughness - + Increase power and decrease toughness Increase power and decrease toughness - + Decrease power and increase toughness Decrease power and increase toughness - + Set &power and toughness... Set &power and toughness... - + Reset p&ower and toughness Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Services - + Hide %1 Hide %1 - + Hide Others Hide Others - + Show All Show All - + Preferences... Preferences... - + Quit %1 Quit %1 - + About %1 About %1 @@ -7272,17 +9215,17 @@ This setting means you'll only see the default printing for each card, inst QObject - + Cockatrice card database (*.xml) Cockatrice card database (*.xml) - + All files (*.*) All files (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7302,17 +9245,22 @@ This setting means you'll only see the default printing for each card, inst Tokens - + + Maybeboard + + + + Overwrite Existing File? Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7329,7 +9277,7 @@ You will have to use the Set Manager, available through Card Database -> Mana Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7346,100 +9294,110 @@ You can also use the Set Manager to adjust custom sort order for printings in th Are you sure you would like to disable this feature? - + Confirm Change Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Save - + Save All Save All - + Open Open - + &Yes &Yes - + Yes to &All Yes to &All - + &No &No - + N&o to All N&o to All - + Abort Abort - + Retry Retry - + Ignore Ignore - + Close Close - + Cancel Cancel - + Discard Discard - + Help Help - + Apply Apply - + Reset Reset - + Restore Defaults Restore Defaults @@ -7447,121 +9405,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Name - + ID ID - + Upload time Upload time + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Name - + Players Players - + Keep Keep - + Time started Time started - + Duration (sec) Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7603,37 +9629,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Rooms - + Joi&n Joi&n - + Room Room - + Description Description - + Permissions Permissions - + Players Players - + Games Games @@ -7641,7 +9667,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay S&ay @@ -7649,32 +9675,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Choose an action from the table - + Hit the key/combination of keys you want to set for this action Hit the key/combination of keys you want to set for this action - + Invalid key Invalid key - + Shortcut already in use by: Shortcut already in use by: - + Clear Clear - + Restore default Restore default @@ -7707,56 +9733,92 @@ Fine-tune individual colours in the grid afterwards. Release date + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Restore all default shortcuts - + Do you really want to restore all default shortcuts? Do you really want to restore all default shortcuts? - + Clear all default shortcuts Clear all default shortcuts - + Do you really want to clear all shortcuts? Do you really want to clear all shortcuts? - + Section: Section: - + Action: Action: - + Shortcut: Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts How to set custom shortcuts - + Clear all shortcuts Clear all shortcuts - + Search by shortcut name Search by shortcut name @@ -7812,12 +9874,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard &Sideboard - + &View sideboard &View sideboard @@ -7825,27 +9887,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Enable &sounds - + Current sounds theme: Current sounds theme: - + Test system sound engine Test system sound engine - + Sound settings Sound settings - + Master volume Master volume @@ -7853,48 +9915,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed Spoilers download failed - + No internet connection No internet connection - + Error Error - + Spoilers already up to date Spoilers already up to date - + No new spoilers added No new spoilers added - + Spoilers have been updated! Spoilers have been updated! - + Last change: Last change: @@ -7902,157 +9964,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default Default - + No reply received from the release update server. No reply received from the release update server. - + Invalid reply received from the release update server. Invalid reply received from the release update server. - + No reply received from the tag update server. No reply received from the tag update server. - + Invalid reply received from the tag update server. Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8060,17 +10135,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List Add to Buddy List - + Add to Ignore List Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account Account @@ -8191,118 +10282,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. Desc. - - + + AND AND - - + + Require ALL selected colors Require ALL selected colors - - + + Deck name... Deck name... - - + + Owner... Owner... - - + + Packages Packages - - + + Advanced Filters Advanced Filters - + Bracket: Bracket: - - + + Any Any - - + + Contains card... Contains card... - - + + Commander... Commander... - - + + Tag... Tag... - - + + Deck Size Deck Size - + Cards: Cards: - - + + Asc. Asc. - + Sort by: Sort by: - + Filter by: Filter by: - + Display Settings Display Settings - - + + Search Search - - + + Formats Formats @@ -8315,42 +10406,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8363,52 +10454,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info Card Info - + Deck Deck - + Filters Filters - + &View &View - + Card Database Card Database - + Printing Printing - + Visible Visible - + Floating Floating - + Reset layout Reset layout - + Deck: %1 Deck: %1 @@ -8416,49 +10507,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 Visual Deck: %1 - + &Visual Deck Editor &Visual Deck Editor - - + + Card Info Card Info - - + + Deck Deck - + &View &View - + Printing Printing - + Visible Visible - + Floating Floating - + Reset layout Reset layout @@ -8466,22 +10557,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View Visual Deck View - + Visual Database Display Visual Database Display - + Deck Analytics Deck Analytics - + Sample Hand Sample Hand @@ -8489,139 +10580,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Local file system - + Server deck storage Server deck storage - - + + Open in deck editor Open in deck editor - + Rename deck or folder Rename deck or folder - + Upload deck Upload deck - + Download deck Download deck - - - - + + + + New folder New folder - - + + Delete Delete - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Open decks folder - + Rename local folder Rename local folder - + Rename local file Rename local file - + New name: New name: - - - - + + + + + + + Error Error - + Rename failed Rename failed - - + + + Invalid deck file Invalid deck file - + Enter deck name Enter deck name - + This decklist does not have a name. Please enter a name: This decklist does not have a name. Please enter a name: - + Unnamed deck Unnamed deck - + Failed to upload deck to server Failed to upload deck to server - + Delete local file Delete local file - + Are you sure you want to delete the selected files? Are you sure you want to delete the selected files? - + Delete remote decks Delete remote decks - + Are you sure you want to delete the selected decks? Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Name of new folder: - + Deck Storage Deck Storage @@ -8629,20 +10818,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage Visual Deck Storage - + Error Error - + Could not open deck at %1 Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8655,27 +11095,27 @@ Please enter a name: TabEdhRecMain - + &Cards &Cards - + Top Commanders Top Commanders - + Tags Tags - + Search for a card ... Search for a card ... - + Search Search @@ -8688,191 +11128,207 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Game - - + + Player List Player List - - + + + Invite + + + + + Card Info Card Info - - + + Messages Messages - - + + Replay Timeline Replay Timeline - + &Phases &Phases - + &Game &Game - + Next &phase Next &phase - + Next phase with &action Next phase with &action - + Next &turn Next &turn - + Reverse turn order Reverse turn order - + &Remove all local arrows &Remove all local arrows - + Rotate View Cl&ockwise Rotate View Cl&ockwise - + Rotate View Co&unterclockwise Rotate View Co&unterclockwise - + Game &information Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede Un&concede - - - + + + &Concede &Concede - + &Leave game &Leave game - + C&lose replay C&lose replay - + &Focus Chat &Focus Chat - + &Say: &Say: - + Selected cards Selected cards - + &View &View - + Visible Visible - + Floating Floating - + Reset layout Reset layout - + Concede Concede - + Are you sure you want to concede this game? Are you sure you want to concede this game? - + Unconcede Unconcede - + You have already conceded. Do you want to return to this game? You have already conceded. Do you want to return to this game? - + Leave game Leave game - + Are you sure you want to leave this game? Are you sure you want to leave this game? - + A player has joined game #%1 A player has joined game #%1 - + %1 has joined the game %1 has joined the game - + You have been kicked out of the game. You have been kicked out of the game. @@ -8888,158 +11344,158 @@ Please enter a name: TabLog - + Logs Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs Room Logs - + Game Logs Game Logs - + Chat Logs Chat Logs - - + + Error Error - + You must select at least one filter. You must select at least one filter. - + You have to select a valid number of days to locate. You have to select a valid number of days to locate. - + Username: Username: - + IP Address: IP Address: - + Game Name: Game Name: - + GameID: GameID: - + Message: Message: - + Main Room Main Room - + Game Room Game Room - + Private Chat Private Chat - + Past X Days: Past X Days: - + Today Today - + Last Hour Last Hour - + Maximum Results: Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs Get User Logs - + Clear Filters Clear Filters - + Filters Filters - + Log Locations Log Locations - + Date Range Date Range - + Maximum Results Maximum Results - - + + Message History Message History - + Failed to collect message history information. Failed to collect message history information. - + There are no messages for the selected filters. There are no messages for the selected filters. @@ -9047,219 +11503,559 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat Private &chat - + &Leave &Leave - + %1 - Private chat %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from Private message from - + %1 has left the server. %1 has left the server. - + %1 has joined the server. %1 has joined the server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Local file system - + Server replay storage Server replay storage - - + + Watch replay Watch replay - + Rename Rename - - + + New folder New folder - - + + Delete Delete - + Open replays folder Open replays folder - + Download replay Download replay - + Toggle expiration lock Toggle expiration lock - + Get replay share code Get replay share code - - + + Look up replay by share code Look up replay by share code - + Rename local folder Rename local folder - + Rename local file Rename local file - + New name: New name: - + Error Error - + Rename failed Rename failed - + Name of new folder: Name of new folder: - + Delete local file Delete local file - + Are you sure you want to delete the selected files? Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? Are you sure you want to delete the selected replays? - + Failed to get code Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed Failed - + Could not get replay code Could not get replay code - + Replay Share Code Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard Copy to clipboard - + Replay share code Replay share code - + Replay code found Replay code found - + Replay was added, or you already had access to it. Replay was added, or you already had access to it. - + Replay code not found Replay code not found - + Failed to submit code Failed to submit code - + Unexpected error Unexpected error - + Delete remote replay Delete remote replay @@ -9269,65 +12065,478 @@ The more information you put in, the more specific your results will be.Game Replays + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Say: - + Chat Chat - + &Room &Room - + &Leave room &Leave room - + &Clear chat &Clear chat - + Chat Settings... Chat Settings... - + mentioned you. mentioned you. - + Click to view Click to view - + You are flooding the chat. Please wait a couple of seconds. You are flooding the chat. Please wait a couple of seconds. @@ -9335,35 +12544,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server Server - - - - + + + + Error Error - + Failed to join the server room: it doesn't exist on the server. Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. Failed to join the server room due to an unknown error: %1. @@ -9371,97 +12580,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor Deck Editor - + Visual Deck Editor Visual Deck Editor - + EDHRec EDHRec - + Archidekt Archidekt - + Home Home - + &Visual Deck Storage &Visual Deck Storage - + Visual Database Display Visual Database Display - + Server Server - + Account Account - + Deck Storage Deck Storage - + Game Replays Game Replays - + Administration Administration - + Logs Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Are you sure? - + There are still open games. Are you sure you want to quit? There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Click to view - + Your buddy %1 has signed on! Your buddy %1 has signed on! - + Unknown Event Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9472,39 +12757,39 @@ This message might mean there is a new version of Cockatrice available or this s To update your client, go to Help -> Check for Updates. - + Idle Timeout Idle Timeout - + You are about to be logged out due to inactivity. You are about to be logged out due to inactivity. - + Promotion Promotion - + You have been promoted. Please log out and back in for changes to take effect. You have been promoted. Please log out and back in for changes to take effect. - + Warned Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) You have received the following message from the server. @@ -9524,6 +12809,34 @@ Please refrain from engaging in this activity or further actions may be taken ag Visual Database Display + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9538,6 +12851,65 @@ Please refrain from engaging in this activity or further actions may be taken ag Unable to analyze the deck. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9609,57 +12981,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9667,216 +13034,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details User &details - + Private &chat Private &chat - + Show this user's &games Show this user's &games - + + View this user's &public decks + + + + Add to &buddy list Add to &buddy list - + Remove from &buddy list Remove from &buddy list - + Add to &ignore list Add to &ignore list - + Remove from &ignore list Remove from &ignore list - + Kick from &game Kick from &game - + + Report user + + + + Warn user Warn user - + View user's war&n history View user's war&n history - + Ban from &server Ban from &server - + View user's &ban history View user's &ban history - + &Promote user to moderator &Promote user to moderator - + Dem&ote user from moderator Dem&ote user from moderator - + Promote user to &judge Promote user to &judge - + Demote user from judge Demote user from judge - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes View admin notes - - + Investigate user + + + + + + Error Error - + This user does not exist. This user does not exist. - + You are being ignored by %1 and can't see their games. You are being ignored by %1 and can't see their games. - + Could not get %1's games. Could not get %1's games. - + %1's games %1's games - - - + + + Ban History Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. User has never been banned. - + Failed to collect ban information. Failed to collect ban information. - - - + + + Warning History Warning History - + Warning Time;Moderator;User Name;Reason Warning Time;Moderator;User Name;Reason - + User has never been warned. User has never been warned. - + Failed to collect warning information. Failed to collect warning information. - + Failed to get admin notes. Failed to get admin notes. - - + + Success Success - + Successfully promoted user. Successfully promoted user. - + Successfully demoted user. Successfully demoted user. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Failed - + Failed to promote user. Failed to promote user. - + Failed to demote user. Failed to demote user. - + Copy hash to clipboard Copy hash to clipboard - + Remove this user's messages Remove this user's messages @@ -9914,61 +13331,61 @@ Please refrain from engaging in this activity or further actions may be taken ag Administrator - + Moderator Moderator - + Registered user Registered user - - + + Unregistered user Unregistered user - + Judge Judge - + Unknown Unknown - + The entered password does not match your account. The entered password does not match your account. - - - - + + + + Information Information - + User information updated. User information updated. - - - - - - + + + + + - - - + + + + Error Error @@ -9992,283 +13409,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n Year, %n Years, - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Day %20%10%n Days %20 - + Enter Password Enter Password - + Password verification is required in order to change your email address Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. This server does not permit you to update your user informations. - + Password changed. Password changed. - + This server does not permit you to change your password. This server does not permit you to change your password. - + The new password is too short. The new password is too short. - + The old password is incorrect. The old password is incorrect. - + Avatar updated. Avatar updated. - + This server does not permit you to update your avatar. This server does not permit you to update your avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - This server does not permit you to update your user informations. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10276,223 +13702,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings General interface settings - + &Double-click cards to play them (instead of single-click) &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases Do not delete &arrows inside of subphases - + Close card view window when last card is removed Close card view window when last card is removed - + Auto focus search bar when card view window is opened Auto focus search bar when card view window is opened - + Annotate card text on tokens Annotate card text on tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Use tear-off menus, allowing right click menus to persist on screen - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Notifications settings - + Enable notifications in taskbar Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect Notify in the taskbar when users in your buddy list connect - + Animation settings Animation settings - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &Tap/untap animation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings Deck editor/storage settings - + Open deck in new tab by default Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: When adding a tag in the visual deck storage to a .txt deck: - - do nothing - do nothing + + Do nothing + - - ask to convert to .cod - ask to convert to .cod + + Ask to convert to .cod + - - always convert to .cod - always convert to .cod + + Always convert to .cod + - + Default deck editor type Default deck editor type - + Classic Deck Editor Classic Deck Editor - + Visual Deck Editor Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Replay settings - + Buffer time for backwards skip via shortcut: Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 Users connected to server: %1 - + Users in this room: %1 Users in this room: %1 - + Buddies online: %1 / %2 Buddies online: %1 / %2 - + Ignored users online: %1 / %2 Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters Increment all card counters - + &Untap all permanents &Untap all permanents - + R&oll die... R&oll die... - + Flip coin - + &Create token... &Create token... - + C&reate another token C&reate another token - + Cr&eate predefined token Cr&eate predefined token @@ -10529,22 +14076,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... Search filter... - + Save Filter Save Filter - + Save all currently applied filters to a file Save all currently applied filters to a file - + Enter filename... Enter filename... @@ -10552,72 +14099,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by Sort by - + Filter by Filter by - + Save and load filters Save and load filters - + Filter by exact card name Filter by exact card name - + Filter by card main-type Filter by card main-type - + Filter by card sub-type Filter by card sub-type - + Filter by set Filter by set - + Filter by format legality Filter by format legality - + Save/Load Save/Load - + Name Name - + Main Type Main Type - + Sub Type Sub Type - + Sets Sets - + Formats Formats @@ -10719,7 +14266,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filter to most recent sets @@ -10727,19 +14274,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... Search sets... - - + + Mode: Exact Match Mode: Exact Match - - + + Mode: Includes Mode: Includes @@ -10785,28 +14332,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Search by card name (or search expressions) - - + + Visual Visual - + Loading database ... Loading database ... - + Clear all filters Clear all filters - + Table Table @@ -10814,43 +14361,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: Group by: - + Change how cards are divided into categories/groups. Change how cards are divided into categories/groups. - + Sort by: Sort by: - + Click and drag to change the sort order within the groups Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat Toggle Layout: Flat @@ -10866,12 +14413,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand Draw a new sample hand - + Sample hand size Sample hand size @@ -10879,17 +14426,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... Type a card name here for suggestions from the database... - + Quick search and add card Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10897,7 +14444,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage Deck Storage @@ -10905,52 +14452,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders Show Folders - + Show Tag Filter Show Tag Filter - + Show Color Identity Show Color Identity - + Show Tags On Deck Previews Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option Show Banner Card Selection Option - + Draw unused Color Identities Draw unused Color Identities - + Unused Color Identities Opacity Unused Color Identities Opacity - + Deck tooltip: Deck tooltip: - + None None - + Filepath Filepath @@ -10958,7 +14510,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) Search by filename (or search expression) @@ -10966,22 +14518,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) Sort Alphabetically (Filename) - + Sort by Last Modified Sort by Last Modified - + Sort by Last Loaded Sort by Last Loaded @@ -10989,17 +14541,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... Loading database ... - + Refresh loaded files Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings Visual Deck Storage Settings @@ -11007,177 +14564,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? Which warning would you like to send? - + Redact all messages from this user in all rooms Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancel - + Warn user for misconduct Warn user for misconduct - - + + Error Error - + User name to send a warning to can not be blank, please specify a user to warn. User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Move selected set to the top - + Move selected set up Move selected set up - + Move selected set down Move selected set down - + Move selected set to the bottom Move selected set to the bottom - - Search by set name, code, or type - Search by set name, code, or type - - - + Default order Default order - + Restore original art priority order Restore original art priority order - + Enable all sets Enable all sets - + Disable all sets Disable all sets - + Enable selected set(s) Enable selected set(s) - + Disable selected set(s) Disable selected set(s) - + Deck Editor Deck Editor - + Use CTRL+A to select all sets in the view. Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) - + Include cards rebalanced for Alchemy [requires restart] Include cards rebalanced for Alchemy [requires restart] - + Card Art Card Art - + + Search by set name, code, type, or release date + + + + How to use custom card art How to use custom card art - + Hints Hints - + Note Note - + Sorting by column allows you to find a set while not changing set priority. Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead Use the current sorting as the set priority instead - + Sorts the set priority using the same column Sorts the set priority using the same column - + Manage sets Manage sets @@ -11185,93 +14767,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) Search by card name (or search expressions) - + Ungrouped Ungrouped - + Group by Type Group by Type - + Group by Mana Value Group by Mana Value - + Group by Color Group by Color - + Unsorted Unsorted - + Sort by Name Sort by Name - + Sort by Type Sort by Type - + Sort by Mana Cost Sort by Mana Cost - + Sort by Colors Sort by Colors - + Sort by P/T Sort by P/T - + Sort by Set Sort by Set - + shuffle when closing shuffle when closing - + pile view pile view - - i18n - - - English - English - - main - + Connect on startup Connect on startup - + Debug to file Debug to file @@ -12055,6 +15629,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard Graveyard + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_en_US.ts b/oracle/translations/oracle_en_US.ts index 394e10b92..b91d56230 100644 --- a/oracle/translations/oracle_en_US.ts +++ b/oracle/translations/oracle_en_US.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Introduction - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. - + Interface language: Interface language: - + Version: Version: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Source selection - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: Download URL: - + Local file: Local file: - + Restore default URL Restore default URL - + Choose file... Choose file... - + Load sets file Load sets file - + Sets file (%1) Sets JSON file (%1) Sets file (%1) - - - - - - - + + + + + Error Error - + The provided URL is not valid. The provided URL is not valid. - - Downloading (0MB) - Downloading (0MB) - - - + Please choose a file. Please choose a file. - + Cannot open file '%1'. Cannot open file '%1'. - - Downloading (%1MB) - Downloading (%1MB) - - - + Network error: %1. Network error: %1. - + Parsing file Parsing file - + Xz extraction failed. Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. Failed to open Zip archive: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Zip extraction failed: the Zip archive doesn't contain exactly one file. - + Zip extraction failed: %1. Zip extraction failed: %1. - + Sorry, this version of Oracle does not support zipped files. Sorry, this version of Oracle does not support zipped files. - + Failed to interpret downloaded data. Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. The file was retrieved successfully, but it does not contain any sets data. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Save spoiler database - + XML; spoiler database (*.xml) XML; spoiler database (*.xml) - + spoiler spoiler - + Spoilers import Spoilers import - + Please specify a compatible source for spoiler data. Please specify a compatible source for spoiler data. - + Download URL: Download URL: - + Local file: Local file: - + Restore default URL Restore default URL - + Choose file... Choose file... - + The spoiler database will be saved at the following location: The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Save token database - + XML; token database (*.xml) XML; token database (*.xml) - + tokens tokens - + Tokens import Tokens import - + Please specify a compatible source for token data. Please specify a compatible source for token data. - + Download URL: Download URL: - + Local file: Local file: - + Restore default URL Restore default URL - + Choose file... Choose file... - + The token database will be saved at the following location: The token database will be saved at the following location: - + Save to a custom path (not recommended) Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Dummy set containing tokens @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle Importer @@ -292,22 +306,22 @@ OutroPage - + Finished Finished - + The wizard has finished. The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Error - + + No set has been imported. No set has been imported. - + Sets imported Sets imported - - A cockatrice database file of %1 MB has been downloaded. - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: The card database will be saved at the following location: - + Save to a custom path (not recommended) Save to a custom path (not recommended) - + &Save &Save - + + Import finished: %1 cards. Import finished: %1 cards. - + + %1: %2 cards imported %1: %2 cards imported - + Save card database Save card database - + XML; card database (*.xml) XML; card database (*.xml) - + The file could not be saved to %1 The file could not be saved to %1 @@ -414,8 +431,9 @@ - Downloading (0MB) - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,7 +605,7 @@ i18n - + English English @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Only run in spoiler mode - + Run in no-confirm background mode Run in no-confirm background mode From c049c5c2b7515157037cb1427c154be4d86298a0 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:41:31 +0200 Subject: [PATCH 063/247] Translate cockatrice_en@source.ts in cs [Manual Sync] (#7570) 13% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'cs'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_cs.ts | 7524 ++++++++++++++++------ 1 file changed, 5554 insertions(+), 1970 deletions(-) diff --git a/cockatrice/translations/cockatrice_cs.ts b/cockatrice/translations/cockatrice_cs.ts index aa15c6fcc..5b130eb06 100644 --- a/cockatrice/translations/cockatrice_cs.ts +++ b/cockatrice/translations/cockatrice_cs.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Nastavit žeton... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': Nová hodnota pro žeton '%1': @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error Chyba - + Could not create themes directory at '%1'. - + Theme settings Nastavení vzhledu - + Current theme: Aktuální vzhled - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Vykreslování karet - + Display card names on cards having a picture Zobrazit jména karet na kartách s obrázky - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Zvětšovat karty při najetí myši - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Rozvržení ruky - + Display hand horizontally (wastes space) Zobrazit ruku horizontálně (plýtvá místem) - + Enable left justification Zapnout - + Table grid layout Rozložení herní mřížky - + Invert vertical coordinate Převrátit vertikální souřadnice - + Minimum player count for multi-column layout: Minimální počet hráčů pro víceřádkové rozvržení: - + Maximum font size for information displayed on cards: Maximální velikost písma pro informace na kartách @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Blokovat & Uživatelské jméno - + ban &IP address Blokovat & IP Adresu - + ban client I&D Banovat I&D klienta - + Ban type Typ banu - + &permanent ban &trvalý ban - + &temporary ban &dočasný ban - + &Days: &Dní: - + &Hours: &Hodin: - + &Minutes: &Minut: - + Duration of the ban Trvání banu - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Uveďte prosím důvod pro ban. Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. - + Please enter the reason for the ban that will be visible to the banned person. Uveďte prosám důvod pro ban, který uvidí banovaná osoba. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Zrušit - + Ban user from server Zabanovat uživatele ze serveru - - - - + + + + Error Chyba - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -499,22 +634,22 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. @@ -522,7 +657,20 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardDatabaseModel - + Name Jméno - + Sets Edice - + Mana cost Sesílací cena - + Card type Typ karty - + P/T S/O - + Color(s) Barva(y) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -744,22 +1036,22 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -795,129 +1087,129 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardSizeWidget - + Card Size @@ -1078,6 +1370,24 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Aktualizovat spoilery - + Success Úspěch - + Download URLs have been reset. - + Add URL - - + + URL: URL: - - + + Edit URL - + Add New URL - + Remove URL - + Updating... Aktualizuji... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Vyberte cestu - + URL Download Priority - + Spoilers Spoilery - + Download Spoilers Automatically Stahovat spoilery automaticky - + Spoiler Location: - + Last Change Poslední změna - + Spoilers download automatically on launch Automaticky stahovat spoilery při spuštění - + Press the button to manually update without relaunching Manuálně aktualizovat bez restartování - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - + Reset Download URLs @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number Počet - + Provider ID - + Card Karta @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Načíst balíček... - + Load remote deck... Načíst balíček ze serveru... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start Připraven začít - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Chyba - + The selected file could not be loaded. Vybraný soubor se nepodařilo načíst. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. Stahuji... - + Known Hosts Známí hostitelé - + Delete the currently selected saved server - + Refresh the server list with known public servers Znovu načíst seznam serverů se známými veřejnými servery - + New Host Nový hostitel - + Name: Jméno: - + &Host: &Hostitel: - + &Port: &Port: - + Player &name: Jméno &hráče: - + P&assword: H&eslo: - + &Save password &Uložit heslo - + A&uto connect A&utomatické připojení - + Automatically connect to the most recent login when Cockatrice opens Automaticky se připojit k poslednímu přihlášení při otevření Cockatrice - + If you have any trouble connecting or registering then contact the server staff for help! Jestli máte potíže se připájením nebo registrací, kontaktujte pracovníky servery pro pomoc! Pouze Anglicky - - + + Webpage Webová stránka - + Reset Password - + Forgot password? - + &Connect &Připojit - + Server Server - + Login Přihlášení - + Server Contact - + Connect to Server Připojit k serveru - + Server URL - + Communication Port Komunikační port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning Varování přihlášení - + The player name can't be empty. Jméno hráče nemůže být prázdné @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Pamatovat si nastavení - + &Description: &Popis: - + P&layers: H&ráči: - + General Obecné - + Game type Typ hry - + &Password: &Heslo: - + Only &buddies can join Jen pro &přátele - + Only &registered users can join Jen pro &registrované - + Joining restrictions Omezení připojení - + &Spectators can watch &Diváci se mohou dívat - + Spectators &need a password to watch Diváci &potřebují ke sledování heslo - + Spectators can &chat Diváci mohou &chatovat - + Spectators can see &hands Diváci mohou vidět do &rukou - + Create game as spectator - + Spectators Diváci - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear &Smazat - + Create game Vytvořit hru - + Game information Informace o hře - + Error Chyba - + Server error. Chyba serveru. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Jméno: - + Token Token - + C&olor: B&arva: - + white bílá - + blue modrá - + black černá - + red červená - + green zelená - + multicolor vícebarevný - + colorless bezbarvý - + &P/T: &S/O: - + &Annotation: &Poznámka: - + &Destroy token when it leaves the table &Token se při odchodu z bojiště zničí - + Create face-down (Only hides name) - + Token data Data tokenů - + Show &all tokens Ukázat &všechny tokeny - + Show tokens from this &deck Ukázat tokeny z tohto &balíčku - + Choose token from list Vybrat token ze seznamu. - + Create token Vytvořit token @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Nebyl vybrán obrázek. @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. Pro odstranění současného avatara potvrďte bez vybrání nového obrázku. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Prohlížet... - + Change avatar Změnit avatara - + Open Image Otevřít obrázek - + Image Files (*.png *.jpg *.bmp) Soubory obrázků (*.png *.jpg *.bmp) - + Invalid image chosen. Vybrán neplatný obrázek @@ -2836,38 +3454,38 @@ Pro odstranění současného avatara potvrďte bez vybrání nového obrázku.< DlgEditPassword - + Old password: Staré heslo: - + New password: Nové heslo: - + Confirm new password: Potvrdit nové heslo: - + Change password Změnit heslo - - + + Error Chyba - + Your password is too short. - + The new passwords don't match. Nová hesla nejsou stejná. @@ -2875,93 +3493,93 @@ Pro odstranění současného avatara potvrďte bez vybrání nového obrázku.< DlgEditTokens - + &Name: &Jméno - + C&olor: B&arva - + white bílá - + blue modrá - + black černá - + red červená - + green zelená - + multicolor vícebarevná - + colorless bezbarvá - + &P/T: &S/O - + &Annotation: &Poznámka: - + Token data Data k tokenu - - + + Add token Přidat token - + Remove token Odstranit token - + Edit custom tokens Upravit vlastní tokeny - + Please enter the name of the token: Zadejte prosím jméno pro token: - + Error Chyba - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Vybrané jméno se nemůže shodovat s již existující kartou nebo tokenem. @@ -3040,7 +3658,8 @@ Pro správné zobrazení si aktivujte si edici 'Token' v "Nastavi - Hide games that have started + Hide started games + Hide games that have started @@ -3076,8 +3695,9 @@ Pro správné zobrazení si aktivujte si edici 'Token' v "Nastavi - &Creator name: - Jméno &tvůrce: + &Host name: + &Creator name: + @@ -3143,52 +3763,52 @@ Pro správné zobrazení si aktivujte si edici 'Token' v "Nastavi DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: &Hostitel: - + &Port: &Port: - + Player &name: Jméno &hráče: - + Email: Email: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. Emailová adresa musí být vyplněna. @@ -3196,37 +3816,37 @@ Pro správné zobrazení si aktivujte si edici 'Token' v "Nastavi DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: &Hostitel: - + &Port: &Port: - + Player &name: &Jméno hráče: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. Jméno hráče musí být vyplněno. @@ -3234,94 +3854,127 @@ Pro správné zobrazení si aktivujte si edici 'Token' v "Nastavi DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: &Hostitel: - + &Port: &Port: - + Player &name: &Jméno hráče: - + Token: Token: - - + + New Password: Nové Heslo: - + Reset Password - + The player name can't be empty. Vyplňte jméno hráče. - - - - + + + + Reset Password Error - + The token can't be empty. Vyplňte token. - + The new password can't be empty. Vyplňte nové heslo. - + Error - + Your password is too short. - + The passwords do not match. Hesla se neshodují. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3405,165 +4058,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: &Hostitel: - + &Port: Port: - + Player &name: Jméno &hráče: - + P&assword: H&eslo: - + Password (again): Heslo (znovu): - + Email: Email: - + Email (again): Email (znovu): - + Country: Země: - + Undefined Neznámý - + Real name: Skutečné jméno: - + Register to server Registrovat se na server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning - + Your password is too short. - + Your passwords do not match, please try again. Vaše hesla se neshodují. - + Your email addresses do not match, please try again. Vaše emailové adresy se neshodují. - + The player name can't be empty. Vyplňte jméno hráče. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3629,46 +4683,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Neznámý error při nahrávání databáze karet. - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Vaše databáze karet je neplatná. - -Cockatrice s neplatnou databází nemusí fungovat správně. - -Je možné že bude nutné znovu spustit Oracle pro obnovení databáze karet. - -Chtěl/a by jste změnit nastavení lokace databáze? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Máte starou verzi databázy karet. - -Může to spůsobit problémy při načítaní informací of kartách nebo jejich obrázků. - -Je možné to spravit spuštěním Oracle pro aktualizaci vaší databáze karet. - -Chcete změnit nastavení lokace databáze? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3677,7 +4697,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3686,7 +4706,7 @@ Would you like to change your database location setting? Chcete změnit nastavení lokace databáze? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3695,7 +4715,7 @@ Would you like to change your database location setting? Chcete změnit nastavení lokace databáze? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3704,104 +4724,235 @@ Would you like to change your database location setting? - - - + + + Error Chyba - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Cesta k adresáři s balíčky je neplatná. Chcete se vrátit a nastavit správnou? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Cesta k adresáři s obrázky je neplatná. Chcete se vrátit a nastavit správnou? - + Settings Nastavení - + + Search settings... + + + + + OK + + + + General Obecné - + Appearance Vzhled - + User Interface Rozhraní - + Storage - + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chat - + Sound Zvuk - + Shortcuts Skratky + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3809,17 +4960,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Další - + Previous Předchozí - + Tip of the Day Tip @@ -3827,164 +4978,174 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel - + Reinstall Znovu instalovat - + Cancel Download Přerušit stahování - + Open Download Page - + Check for Client Updates - - - - + + + + Error Chyba - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 - + Checking for updates... Hledám aktualizace... - + Finished checking for updates Aktualizace byly skontrolovány - + No Update Available Žádná dostupná aktualizace - + Cockatrice is up to date! Cockatrice je v aktuální verzi! - + You are already running the latest version available in the chosen release channel. - + Current version Aktuální verze - + Selected release channel - - + + Update Available Dostupná aktualizace - - + + A new version of Cockatrice is available! Je dostupná nová verze Cockatrice! - - + + New version Nová verze - - + + Released - - + + Changelog - + Do you want to update now? Chcete aktualizovat nyní? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Chyba aktualizace - + An error occurred while checking for updates: Došlo k chybě při kontrole aktualizací: - + An error occurred while downloading an update: Došlo k chybě při stahování aktualizace: - + Installing... Instaluji... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3992,17 +5153,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log @@ -4240,7 +5401,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4248,7 +5409,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Zde napište svůj filtr: @@ -4256,45 +5417,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4302,140 +5506,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Chyba - + Please join the appropriate room first. - + Wrong password. Špatné heslo. - + Spectators are not allowed in this game. Do této hry je divákům přístup zakázán. - + The game is already full. Hra je plná. - + The game does not exist any more. Hra již neexistuje. - + This game is only open to registered users. Hra je určena jen pro registrované. - + This game is only open to its creator's buddies. Hra je dostupná jen pro přátele zakládajícícho. - + You are being ignored by the creator of this game. Zakladatel hry vás ignoruje. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game Připojit ke hře - - Password: - Heslo: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. - + &Filter games &Filtrovat hry: - + C&lear filter Z&rušit filtr: - + C&reate V&ytvořit - + &Join &Připojit se - + Join as judge - + J&oin as spectator P&řipojit se jako divák - + Join as judge spectator - + Games shown: %1 / %2 - + Games Hry @@ -4476,105 +5696,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password heslo - + buddies only jen pro přátele - + reg. users only jen pro registrované - + open decklists - - + + can chat může chatovat - + see hands vidět do rukou - + can see hands může vidět do rukou - + not allowed nepovolené - + Room Místnost - + Age Doba od otevření - + Description Popis - - Creator - Zakladatel + + Host + - + Type Typ - + Restrictions Omezení - + Players Hráči - + Spectators Diváci @@ -4582,158 +5802,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + Česky (Czech) + + + + + + + + + Choose path Vyberte cestu - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Jazyk: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) - + Paths Cesty - + How to help with translations - + Decks directory: Adresář s balíčky: - + Filters directory: - + Replays directory: Adresář se záznamy her - + Pictures directory: Adresář s obrázky: - + Card database: Databáze karet: - + Custom database directory: - + Token database: Databáze tokenů: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice Automaticky spustit Oracle při spouštění nové verze Cockatrice - + Show tips on startup Ukázat tip při spuštění - + Last update check on %1 (%2 days ago) @@ -4741,47 +6078,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4875,59 +6212,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5145,410 +6776,431 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Hráč %1 - + Load replay - + About Cockatrice O Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: Překlad: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - + + Error Chyba - + &Connect... &Připojit... - + &Disconnect &Odpojit - + Start &local game... Spustit &lokální hru... - + &Watch replay... - + &Full screen &Celá obrazovka - + &Register to server... - + &Restore password... - + &Settings... &Nastavení... - + &Exit &Konec - + A&ctions - + &Cockatrice &Cockatrice - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help &Nápověda - + &About Cockatrice &O Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + Jste si jisti? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. @@ -5753,615 +7405,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play - + from their graveyard - + from exile z exilnutých karet - + from their hand - + the top card of %1's library - + the top card of their library - + from the top of %1's library - + from the top of their library - + the bottom card of %1's library - + the bottom card of their library - + from the bottom of %1's library - + from the bottom of their library - + from %1's library - + from their library - + from sideboard ze sideboardu - + from the stack ze stacku - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card kartu - + %1 gives %2 control over %3. %1 předává kontrolu hráči %2 karty %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 dává kartu %2 %3 do hry. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exiluje %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. %1 přesouvá %2%3 do sideboardu. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 sesílá %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. Hra byla ukončena. - + The game has started. Hra začíná. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. %1 nyní sleduje hru. - + You have been kicked out of the game. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6369,115 +8021,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Přidat zprávu - - + + Message: Zpráva: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - + Ignore private messages sent by non-buddy users - - + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6485,42 +8147,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6599,148 +8261,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6863,30 +8511,21 @@ Cockatrice will now reload the card database. Konec kola - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7005,16 +8644,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7035,12 +8930,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7048,25 +8943,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7097,28 +9032,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7134,95 +9069,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -7230,17 +9173,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) @@ -7260,17 +9203,22 @@ This setting means you'll only see the default printing for each card, instead o - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7281,7 +9229,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7292,100 +9240,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults @@ -7393,121 +9351,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Jméno - + ID ID - + Upload time Průběh nahrávání + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7549,37 +9575,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Místnosti - + Joi&n &Připojit - + Room Místnost - + Description Popis - + Permissions - + Players Hráči - + Games Hry @@ -7587,7 +9613,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7595,32 +9621,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7653,56 +9679,92 @@ Fine-tune individual colours in the grid afterwards. + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7756,12 +9818,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7769,27 +9831,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7797,48 +9859,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7846,157 +9908,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8004,17 +10079,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8135,118 +10226,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8259,42 +10350,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8307,52 +10398,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info - + Deck - + Filters - + &View - + Card Database - + Printing - + Visible - + Floating - + Reset layout - + Deck: %1 @@ -8360,49 +10451,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8410,22 +10501,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8433,139 +10524,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Lokální systém souborů - + Server deck storage Balíčky na serveru - - + + Open in deck editor Otevřít v editoru balíčků - + Rename deck or folder - + Upload deck Nahrát balíček - + Download deck Stáhnout balíček - - - - + + + + New folder Nová složka - - + + Delete Smazat - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name Vložit jméno balíčku - + This decklist does not have a name. Please enter a name: Tentobalíček nemá jméno. Prosím vložte jméno: - + Unnamed deck Bezejmenný balíček - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Název nové složky: - + Deck Storage @@ -8573,20 +10762,271 @@ Prosím vložte jméno: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8599,27 +11039,27 @@ Prosím vložte jméno: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8632,191 +11072,207 @@ Prosím vložte jméno: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + + Invite + + + + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases &Fáze - + &Game &Hra - + Next &phase Další &fáze - + Next phase with &action - + Next &turn Další &kolo - + Reverse turn order - + &Remove all local arrows &Odstranit všechny lokální šipky - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede &Ukončit hru - + &Leave game &Opustit hru - + C&lose replay - + &Focus Chat - + &Say: &Chat: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede Ukončit hru - + Are you sure you want to concede this game? Opravdu chcete ukončit tuto hru? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Opustit hru - + Are you sure you want to leave this game? Opravdu chcete opustit tuto hru? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8832,157 +11288,157 @@ Prosím vložte jméno: TabLog - + Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs - + Chat Logs - - + + Error Chyba - + You must select at least one filter. - + You have to select a valid number of days to locate. - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8990,218 +11446,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat - + &Leave &Odejít - + %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1 opustil server. - + %1 has joined the server. %1 se připojil k serveru. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -9211,65 +12007,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Chat: - + Chat Chatovat - + &Room &Místnost - + &Leave room &Opustit místnost - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. Píšete příliš intenzivně. Počkejte pár sekund. @@ -9277,35 +12486,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server - - - - + + + + Error Chyba - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9313,97 +12522,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? - + There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9411,38 +12696,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -9461,6 +12746,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9475,6 +12788,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9544,57 +12916,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9602,216 +12969,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details - + Private &chat - + Show this user's &games - - - Add to &buddy list - - - - - Remove from &buddy list - - - - - Add to &ignore list - - - - - Remove from &ignore list - - - - - Kick from &game - - - - - Warn user - - - View user's war&n history + View this user's &public decks - Ban from &server + Add to &buddy list - View user's &ban history + Remove from &buddy list - &Promote user to moderator + Add to &ignore list - Dem&ote user from moderator + Remove from &ignore list - Promote user to &judge + Kick from &game - Demote user from judge + Report user + Warn user + + + + + View user's war&n history + + + + + Ban from &server + + + + + View user's &ban history + + + + + &Promote user to moderator + + + + + Dem&ote user from moderator + + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9849,61 +13266,61 @@ Please refrain from engaging in this activity or further actions may be taken ag Administrátor - + Moderator - + Registered user Registrovaný - - + + Unregistered user Neregistrovaný - + Judge - + Unknown - + The entered password does not match your account. - - - - + + + + Information - + User information updated. - - - - - - + + + + + - - - + + + + Error Chyba @@ -9927,283 +13344,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. - - - Password changed. - - - This server does not permit you to change your password. + Password changed. - The new password is too short. + This server does not permit you to change your password. + The new password is too short. + + + + The old password is incorrect. - + Avatar updated. - + This server does not permit you to update your avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10211,223 +13637,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings Obecné - + &Double-click cards to play them (instead of single-click) &Pro zahraní karty je třeba dvojklik - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - - Use tear-off menus, allowing right click menus to persist on screen + + + Enabled - - Keep game chat focused when clicking in game (Note: disables card view search bar) - - - - - Notifications settings - - - - - Enable notifications in taskbar - - - - - Notify in the taskbar for game events while you are spectating + + + Automatic + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Use tear-off menus, allowing right click menus to persist on screen + + + + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + + Notifications settings + + + + + Enable notifications in taskbar + + + + + Notify in the taskbar for game events while you are spectating + + + + Notify in the taskbar when users in your buddy list connect - + Animation settings Animace - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &Animace tapnutí/odtapnutí - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10464,22 +14011,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10487,72 +14034,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10654,7 +14201,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10662,19 +14209,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10720,28 +14267,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10749,43 +14296,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10801,12 +14348,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10814,17 +14361,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10832,7 +14379,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10840,52 +14387,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10893,7 +14445,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10901,22 +14453,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10924,17 +14476,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10942,177 +14499,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error Chyba - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - - How to use custom card art + + Search by set name, code, type, or release date + How to use custom card art + + + + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -11120,93 +14702,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing zamíchat po zavření - + pile view - - i18n - - - English - Česky (Czech) - - main - + Connect on startup - + Debug to file @@ -11990,6 +15564,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard Hřbitov + + + Report Queue + + + + + Moderation + + From 03bfa2dcc6df6b0fd635c55ad87d60c9c9199d30 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:06 +0200 Subject: [PATCH 064/247] Updates for project Cockatrice and language en@pirate (#7567) * Translate oracle_en@source.ts in en@pirate [Manual Sync] 7% of minimum 5% translated source file: 'oracle_en@source.ts' on 'en@pirate'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in en@pirate [Manual Sync] 5% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'en@pirate'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- .../translations/cockatrice_en@pirate.ts | 10533 ++++++++++++---- oracle/translations/oracle_en@pirate.ts | 254 +- 2 files changed, 8160 insertions(+), 2627 deletions(-) diff --git a/cockatrice/translations/cockatrice_en@pirate.ts b/cockatrice/translations/cockatrice_en@pirate.ts index 40aa82530..64663db61 100644 --- a/cockatrice/translations/cockatrice_en@pirate.ts +++ b/cockatrice/translations/cockatrice_en@pirate.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Set piece o' eight... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Set piece o' eight - + New value for counter '%1': Freshly formed value for piece o' eight '%1': @@ -23,12 +23,12 @@ AbstractDlgDeckTextEdit - + &Refresh - + Parse Set Name and Number (if available) @@ -36,79 +36,130 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + + AccountSetupPage - - No deck was selected to be exported. + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + + + AddAnalyticsPanelDialog + + + Add Analytics Panel AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -116,202 +167,339 @@ Please check that the directory is writable and try again. AllZonesCardAmountWidget - + Mainboard - + Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - - Enabling this feature will disable the use of the Printing Selector. - -You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. - -You will have to use the Set Manager, available through Card Database -> Manage Sets. - -Are you sure you would like to enable this feature? - - - - - Disabling this feature will enable the Printing Selector. - -You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. - -You can also use the Set Manager to adjust custom sort order for printings in the Printing Selector (other sort orders like alphabetical or release date are available). - -Are you sure you would like to disable this feature? - - - - - Confirm Change - - - - + Theme settings Theme settin's - + Current theme: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + + Show game filter toolbar above list in room tab + + + + Card rendering C-arrr-d Scribblin' - + Display card names on cards having a picture - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + + System + + + + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + + Active theme palette: + + + + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout - + Display hand horizontally (wastes space) - + Enable left justification - + Table grid layout - + Invert vertical coordinate - + Minimum player count for multi-column layout: - + Maximum font size for information displayed on cards: + + ArchidektApiResponseDeckDisplayWidget + + + Back to results + + + + + Open Deck in Deck Editor + + + BackgroundSources @@ -333,112 +521,112 @@ Are you sure you would like to disable this feature? BanDialog - + ban &user name Curse da landlubber's name - + ban &IP address Curse da landlubbers port o call - + ban client I&D Curse da landlubber's Jolly Roger - + Ban type Curse type - + &permanent ban - + &temporary ban - + &Days: - + &Hours: - + &Minutes: - + Duration of the ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Me hearty, enter t' reason for t' ban. This be only saved for captains and cannot be seen by t' brutally banned scallywag. - + Please enter the reason for the ban that will be visible to the banned person. Me hearty, enter t' reason for t' ban that be visible to t' brutally banned scallywag. - + Redact all messages from this user in all rooms - + &OK Aye - + &Cancel Belay that - + Ban user from server Banish the scurvy dog from the ship! - - - - + + + + Error Cap'n? Thar be a problem - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -446,152 +634,378 @@ This be only saved for captains and cannot be seen by t' brutally banned sc BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + Banner preview + + + + + + + + Zoom %1× + + + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Name - + Sets Fleets - + Mana cost Magic tax - + Card type Type 'o magic - + P/T P/T - + Color(s) Colour(z) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Add to Zone + + + + + Create &new zone... + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter - + AND Logical conjunction operator used in card filter AND - + OR Logical disjunction operator used in card filter OR - + AND NOT Negated logical conjunction operator used in card filter AND NOT - + OR NOT Negated logical disjunction operator used in card filter OR NOT - + Name - + + Name (Exact) + + + + Type - + Color - + Text - + Set - + Mana Cost - + Mana Value - + Rarity - + Power - + Toughness - + Loyalty - + Format - + Main Type - + Sub Type @@ -622,22 +1036,22 @@ This be only saved for captains and cannot be seen by t' brutally banned sc CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -645,17 +1059,27 @@ This be only saved for captains and cannot be seen by t' brutally banned sc CardInfoTextWidget - + Name: + Set: + + + + + Collector Number: + + + + Related cards: - + Unknown card: @@ -663,132 +1087,150 @@ This be only saved for captains and cannot be seen by t' brutally banned sc CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - - Toggle &normal untapping + + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget - + Card Size @@ -796,142 +1238,160 @@ This be only saved for captains and cannot be seen by t' brutally banned sc CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser - + Parse error at line %1 col %2: @@ -939,29 +1399,572 @@ This be only saved for captains and cannot be seen by t' brutally banned sc CockatriceXml4Parser - + Parse error at line %1 col %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' + + DeckAnalyticsWidget + + + Add Panel + + + + + Remove Panel + + + + + Save Layout + + + + + Load Layout + + + + + Include Sideboard + + + + + DeckEditorCardDatabaseDockWidget + + + Card Database + + + DeckEditorCardInfoDockWidget - + Card Info @@ -969,47 +1972,17 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1017,87 +1990,158 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckEditorDeckDockWidget - + + Loading Database... + + + + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + + Format: + + + + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1105,17 +2149,17 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1123,114 +2167,119 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - - - Load deck from cl&ipboard... - - - - - Edit deck in clipboard - - - - - - Annotated - - - - - - Not Annotated - - - Save deck to clipboard + Share deck... - Annotated (No set info) + Load deck from cl&ipboard... - Not Annotated (No set info) + Edit deck in clipboard + + + + + + Annotated - &Print deck... + + Not Annotated - Load deck from online service... - - - - - &Send deck to online service + Save deck to clipboard - Create decklist (decklist.org) - - - - - Create decklist (decklist.xyz) + Annotated (No set info) + Not Annotated (No set info) + + + + + &Print deck... + + + + + Load deck from online service... + + + + + &Send deck to online service + + + + + Create decklist (decklist.org) + + + + + Create decklist (decklist.xyz) + + + + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1238,7 +2287,7 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1246,194 +2295,213 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckEditorSettingsPage - - + + Update Spoilers - - + Success - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL - - + + URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Map yer scurvy way - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - - - - + Reset Download URLs + + + DeckListHistoryManagerWidget - - On-disk cache for downloaded pictures + + Undo - - In-memory cache for pictures not currently on screen + + Redo + + + + + Undo/Redo history + + + + + Click on an entry to revert to that point in the history. + + + + + [redo] + + + + + [undo] DeckListModel - + Count - + Set - + Number - + Provider ID - + Card @@ -1441,12 +2509,12 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1454,17 +2522,22 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -1472,7 +2545,7 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -1480,62 +2553,62 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -1543,98 +2616,216 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed + + DeckStateManager + + + Rename deck to "%1" from "%2" + + + + + Updated comments (was %1 chars, now %2 chars) + + + + + Set banner card to %1 (%2) + + + + + Set playmat to %1 + + + + + Tags changed + + + + + Set format to %1 + + + + + Added (%1): %2 (%3) %4 + + + + + Moved to %1 1 × "%2" (%3) + + + + + Removed "%1" (all copies) + + + + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + + %1 1 × "%2" (%3) + + + + + Added + + + + + Removed + + + DeckStatsInterface @@ -1652,94 +2843,122 @@ This be only saved for captains and cannot be seen by t' brutally banned sc DeckViewContainer - + Load deck... - + Load remote deck... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Cap'n? Thar be a problem - + The selected file could not be loaded. Cap'n yer file can't be loaded. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -1752,143 +2971,143 @@ This will kick all non-ready players from the game. - + Known Hosts - + Delete the currently selected saved server - + Refresh the server list with known public servers - + New Host - + Name: - + &Host: &Cap'n - + &Port: - + Player &name: Matey &name: - + P&assword: &A stupendously secret code: - + &Save password Record t' &stupendously secret code - + A&uto connect - + Automatically connect to the most recent login when Cockatrice opens - + If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage - + Reset Password - + Forgot password? - + &Connect - + Server - + Login - + Server Contact - + Connect to Server - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning - + The player name can't be empty. @@ -1981,32 +3200,37 @@ This will kick all non-ready players from the game. - + + Create game as judge + + + + Game setup options - + &Clear - + Create game - + Game information - + Error Cap'n? Thar be a problem - + Server error. Cap'n? Thar be a problem wi' t' ship. @@ -2014,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Name: - + Token - + C&olor: C&olor - + white pure white - + blue sea blue - + black deathly black - + red blood red - + green emerald green - + multicolor multicolor - + colorless colorless - + &P/T: &P/T: - + &Annotation: - + &Destroy token when it leaves the table - + Create face-down (Only hides name) - + Token data - + Show &all tokens - + Show tokens from this &deck - + Choose token from list - + Create token @@ -2112,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2166,39 +3390,44 @@ This will kick all non-ready players from the game. DlgEditAvatar - - + + No image chosen. No pic be picked! - + To change your avatar, choose a new image. To remove your current avatar, confirm without choosing a new image. + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... - + Change avatar Change yer pic - + Open Image Open Pic - + Image Files (*.png *.jpg *.bmp) - + Invalid image chosen. Invalid pic be picked! @@ -2206,17 +3435,17 @@ To remove your current avatar, confirm without choosing a new image. DlgEditDeckInClipboard - + Edit deck in clipboard - + Error - + Invalid deck list. @@ -2224,38 +3453,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: Old stupendously secret code: - + New password: Freshly formed stupendously secret code: - + Confirm new password: Confirm freshly formed stupendously secret code: - + Change password Change t' stupendously secret code - - + + Error Cap'n? Thar be a problem - + Your password is too short. - + The new passwords don't match. T' freshly formed stupendously secret codes be failin' to match! @@ -2263,83 +3492,83 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: &Name: - + C&olor: C&olor - + white pure white - + blue sea blue - + black deathly black - + red blood red - + green emerald green - + multicolor multicolor - + colorless colorless - + &P/T: &P/T: - + &Annotation: - + Token data - - + + Add token - + Remove token - + Edit custom tokens - + Please enter the name of the token: @@ -2373,12 +3602,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Real name: - + Edit user profile @@ -2416,112 +3645,115 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Hide 'buddies only' games - + Hide full games - - Hide games that have started + + Hide started games + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - - Hide games not created by buddy + + Hide games not created by buddies + Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: - - &Creator name: - &Cap'n name: + + &Host name: + &Creator name: + - + General - + &Game types - + at &least: - + at &most: - + Maximum player count - + Restrictions - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games @@ -2704,10 +3936,43 @@ Make sure to enable the 'Token' set in the "Manage sets" dia + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -2715,17 +3980,17 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgLoadDeckFromClipboard - + Load deck from clipboard Find a flagship from t' clipplank - + Error Cap'n? Thar be a problem - + Invalid deck list. Invalid flagship list. @@ -2733,43 +3998,43 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgLoadDeckFromWebsite - + Paste a link to a decklist site here to import it. (Archidekt, Deckstats, Moxfield, and TappedOut are supported.) - - - - - + + + + + Load Deck from Website - + No parser available for this deck provider. (Archidekt, Deckstats, Moxfield, and TappedOut are supported.) - + Network error: %1 - + Received empty deck data. - + Failed to parse deck data: %1 - + The provided URL is not recognized as a valid deck URL. Valid deck URLs look like this: @@ -2783,155 +4048,589 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLoadRemoteDeck - + Load deck Find a flagship + + DlgLocalGameOptions + + + Players: + + + + + General + + + + + Starting life total: + + + + + Game setup options + + + + + Remember settings + + + + + Local game options + + + + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: &Cap'n - + &Port: - + Player &name: Matey &name: - + P&assword: &A stupendously secret code: - + Password (again): - + Email: - + Email (again): - + Country: - + Undefined - + Real name: - + Register to server Board t' ship - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning - + Your password is too short. - + Your passwords do not match, please try again. - + Your email addresses do not match, please try again. - + The player name can't be empty. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -2939,62 +4638,55 @@ Your email will be used to verify your account. DlgSelectSetForCards - + Unmodified Cards: - + Modified Cards: - + Check Sets to enable them. Drag-and-Drop to reorder them and change their priority. Cards will use the printing of the highest priority enabled set. - + Clear all set information - + Set all to preferred + + + Bulk modified printings. + + + + + Cleared all printing information. + + + + + Set all printings to preferred. + + DlgSettings - + Unknown Error loading card database Cap'n? Thar be a unknown problem wi' t' dock - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3003,21 +4695,21 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3026,99 +4718,235 @@ Would you like to change your database location setting? - - - + + + Error Cap'n? Thar be a problem - + The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings Cap'n's orders - + + Search settings... + + + + + OK + + + + General - + Appearance - + User Interface - + + Storage + + + + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat - + Sound - + Shortcuts + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3144,164 +4972,174 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel - + Reinstall - + Cancel Download - + Open Download Page - + Check for Client Updates - - - - + + + + Error - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 - + Checking for updates... - + Finished checking for updates - + No Update Available - + Cockatrice is up to date! - + You are already running the latest version available in the chosen release channel. - + Current version - + Selected release channel - - + + Update Available - - + + A new version of Cockatrice is available! - - + + New version - - + + Released - - + + Changelog - + Do you want to update now? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3309,21 +5147,153 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log + + DrawProbabilityConfigDialog + + + Draw Probability Settings + + + + + Criteria: + + + + + Card Name + + + + + Type + + + + + Subtype + + + + + Mana Value + + + + + Exactness: + + + + + At least + + + + + Exactly + + + + + Quantity (N): + + + + + Cards drawn (M): + + + + + + cards + + + + + DrawProbabilityWidget + + + Draw Probability + + + + + Probability of drawing + + + + + Card Name + + + + + Type + + + + + Subtype + + + + + Mana Value + + + + + At least + + + + + Exactly + + + + + card(s) having drawn at least + + + + + cards + + + + + Category + + + + + Qty + + + + + Odds (%) + + + EdhrecApiResponseCardInclusionDisplayWidget @@ -3393,33 +5363,39 @@ You may have to manually download the new version. + + EdhrecCommanderApiResponseBracketNavigationWidget + + + Game Changers + + + + + EdhrecCommanderApiResponseBudgetNavigationWidget + + + Budget + + + EdhrecCommanderApiResponseNavigationWidget - + Combos - + Average Deck - - - Game Changers - - - - - Budget - - EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -3427,7 +5403,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -3435,45 +5411,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3481,226 +5500,295 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Cap'n? Thar be a problem - + Please join the appropriate room first. - + Wrong password. Ye don't know the stupendously secret code! - + Spectators are not allowed in this game. Liliy-livered landlubbers - get off me ship! - + The game is already full. T' ship be full now. - + The game does not exist any more. T' ship be vanishin'! - + This game is only open to registered users. - + This game is only open to its creator's buddies. T' ship be only open for the cap'n's first mates. - + You are being ignored by the creator of this game. T' cap'n be ignorin' ye. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + + Join Game as Judge + + + + + Spectate Game as Judge + + + + + Join game Board ship - - Password: - Stupendously secret code: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. - + &Filter games - + C&lear filter - + C&reate - + &Join - + + Join as judge + + + + J&oin as spectator B&oard as landlubber - + + Join as judge spectator + + + + Games shown: %1 / %2 - + Games Ships + + GameSelectorQuickFilterToolBar + + + All types + + + + + Filter by game name... + + + + + Filter by game type/format + + + + + Hide games not created by buddies + + + + + Hide full games + + + + + Hide started games + + + GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password stupendously secret code - + buddies only first mates only - + reg. users only - + open decklists - - + + can chat - + see hands - + can see hands - + not allowed - + Room - + Age - + Description - - Creator - Cap'n + + Host + - + Type - + Restrictions - + Players Mateys - + Spectators Landlubbers @@ -3708,143 +5796,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + English, arr! (Pirate English) + + + + + + + + + Choose path - - Personal settings - Cap'n's orders for t' cap'n + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + - + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + + Language settings + + + + Language: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + + Version settings + + + + + Card database + + + + + Startup settings + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) - + Paths - + How to help with translations - + Decks directory: - + Filters directory: - + Replays directory: - + Pictures directory: - + Card database: Dock: - + Custom database directory: - + Token database: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -3852,47 +6072,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -3900,305 +6120,649 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - - &Sort hand + + Sort hand by... - - Take &mulligan + + Name - + + Type + + + + + Mana Value + + + + + Take &mulligan (Choose hand size) + + + + + Take mulligan (Same hand size) + + + + + Take mulligan (Hand size - 1) + + + + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - Reveal r&andom card to... + + + All players - - - &All players + + Reveal r&andom card to... + + + + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + + Browse Archidekt + + + + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + + Move top cards to graveyard face down... + + + + Move top cards to &exile... - + + Move top cards to exile face down... + + + + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + + Move bottom cards to graveyard face down... + + + + Move bottom cards to &exile... - + + Move bottom cards to exile face down... + + + + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4206,1416 +6770,1244 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - - - - - There are too many concurrent connections from your address. - - - - - Banned by moderator - Cap'n's thrown ye outta tha ship! - - - - Expected end time: %1 - - - - - This ban lasts indefinitely. - - - - - Scheduled server shutdown. - - - - - - Invalid username. - Yarrr, that ain't ye name ye scurvy landlubber! - - - - You have been logged out due to logging in at another location. - - - - - Connection closed - - - - - The server has terminated your connection. -Reason: %1 - - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - - - - - Scheduled server shutdown - - - - - - Success - - - - - Registration accepted. -Will now login. - - - - - Account activation accepted. -Will now login. - - - - - Number of players - Number o' mateys - - - - Please enter the number of players. - Me hearty, enter t' number o' mateys - - - - + + Player %1 Matey %1 - + Load replay - + About Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Cap'n? Thar be a problem - - Server timeout - T' ship be timin' out! - - - - Failed Login - - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - - - - - There is already an active session using this user name. -Please close that session first and re-login. - - - - - - You are banned until %1. - - - - - - You are banned indefinitely. - - - - - This server requires user registration. Do you want to register now? - - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - - - - - Unknown login error: %1 - - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - - Your username must respect these rules: - - - - - is %1 - %2 characters long - - - - - can %1 contain lowercase characters - - - - - - - - NOT - - - - - can %1 contain uppercase characters - - - - - can %1 contain numeric characters - - - - - can contain the following punctuation: %1 - - - - - first character can %1 be a punctuation mark - - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - - Registration denied - - - - - Registration is currently disabled on this server - - - - - There is already an existing account with the same user name. - - - - - It's mandatory to specify a valid email address when registering. - - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - The code be too piddly to be stupendously secret. - - - - Registration failed for a technical problem on the server. - - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - - - - - Account activation failed - - - - - Socket error: %1 - - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - - - - - Connecting to %1... - Boarding %1 - - - - Registering to %1 as %2... - - - - - Disconnected - Disembarked - - - - Connected, logging in at %1 - - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... &Board ye ship... - + &Disconnect &Disembark - + Start &local game... Built &local ship - + &Watch replay... - + &Full screen - + &Register to server... - + &Restore password... - + &Settings... Cap'n'&s orders - + &Exit Dis&embark - + A&ctions - + &Cockatrice - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help - + &About Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes AYE! - - + + No NAY! - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information - + A card database update is already running. A dock update be runnin' already! - + Unable to run the card database updater: Cap'n! We can't run the dock updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. + + + ManaBaseConfigDialog - - - - Reset Password + + Mana Base Configuration - - Your password has been reset successfully, you can now log in using the new credentials. + + Display type: - - Failed to reset user account password, please contact the server operator to reset your password. + + pie - - Activation request received, please check your email for an activation token. + + bar + + + + + combinedBar + + + + + Filter Colors (optional): + + + + + OK + + + + + Cancel ManaBaseWidget - - + Mana Base + + ManaCurveConfigDialog + + + Group By: + + + + + type + + + + + color + + + + + subtype + + + + + power + + + + + toughness + + + + + Filters (optional): + + + + + Show main bar row + + + + + Show per-category rows + + + ManaCurveWidget - - + Mana Curve + + ManaDevotionConfigDialog + + + Display type: + + + + + pie + + + + + bar + + + + + combinedBar + + + + + Filter Colors (optional): + + + ManaDevotionWidget - - + Mana Devotion + + ManaDistributionConfigDialog + + + Top display type: + + + + + pie + + + + + bar + + + + + Colors: + + + + + Show per-color rows + + + + + ManaDistributionSingleDisplayWidget + + + %1 pips (%2 cards) + + + + + %1 mana (%2 cards) + + + + + ManaDistributionWidget + + + Mana Production + Devotion + + + + + Mana Distribution Settings + + + MessageLogWidget - + from play - + from their graveyard - + from exile from the darkest depths o' the lass's Davy Jones' Locker - + from their hand - + the top card of %1's library the top card o' %1's library - + the top card of their library - + from the top of %1's library from the top of %1's library - + from the top of their library - + the bottom card of %1's library the bottom card o' %1's library - + the bottom card of their library - + from the bottom of %1's library from the bottom o' %1's library - + from the bottom of their library - + from %1's library from %1's library - + from their library - + from sideboard from sideplank - + from the stack - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). %1 be loadin' a flagship (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 be loadin' a flagship wi' %2 sideplank cards (%3). - + %1 destroys %2. - + a card - + %1 gives %2 control over %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. - + + %1 puts %2%3 into their graveyard face down. + + + + %1 puts %2%3 into their graveyard. - + + %1 exiles %2%3 face down. + + + + %1 exiles %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. - + + %1 plays %2%3 face down. + + + + %1 plays %2%3. - + + %1 moves %2%3 to custom zone '%4' face down. + + + + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. T' ship has been sunk! - + The game has started. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. - + You have been kicked out of the game. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. Yer mate %1 wants a duel! - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -5623,110 +8015,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message - - + + Message: - + Edit message - + Chat settings Cap'n's orders for t' chat - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -5734,37 +8141,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + + T&able + + + + &Hand - + &Graveyard - + &Exile @@ -5777,47 +8189,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -5840,65 +8252,197 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + + Save + + + + + Delete this scheme's custom palette and revert to the theme default (or the application palette) + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Cannot save: this theme has no writable directory + + + + + Write palette-%1.toml and reload the theme + + + + + + Save failed + + + + + + Could not write %1 to: +%2 + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + + Phase - + Unknown Phase - + Untap - + Upkeep - + Draw - + First Main - + Beginning of Combat - + Declare Attackers - + Declare Blockers - + Combat Damage - + End of Combat - + Second Main - + End/Cleanup @@ -5906,253 +8450,552 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step Step where ye be untappin' - + Upkeep step Step where ye be upkeepin' - + Draw step Step where ye be drawin' - + First main phase First main phase - + Beginning of combat step Step where ye begin fightin' - + Declare attackers step Step where ye be orderin' ye men to board - + Declare blockers step Step where ye be orderin' ye men to repel boarders - + Combat damage step Step where people get hurt - + End of combat step Step where ye stop fightin' - + Second main phase Second main phase - + End of turn step Step where ye be endin' ye turn - PictureLoader + PlayerActions - - en - code for scryfall's language property, not available for all languages + + + + + grave + + + + + + + + exile - PlayerActions + PlayerDialogs - + View top cards of library - - - - - - - - - - - + + + + + + + + + Number of cards: (max. %1) - + View bottom cards of library - + Shuffle top cards of library - + Shuffle bottom cards of library - + Draw hand - + 0 and lower are in comparison to current hand size - + Draw cards - - Move top cards to grave + + Move top cards to %1 - - Move top cards to exile + + Move bottom cards to %1 - - Move bottom cards to grave - - - - - Move bottom cards to exile - - - - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters + + + PlaymatCollectionDialog - - S&ay + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + PrintingDisabledInfoWidget + + + The Printing Selector is disabled because you have currently enabled the setting to override all selected printings with personal set preferences. + +This setting means you'll only see the default printing for each card, instead of being able to select a printing, and will not see the printings other people have selected. + + + + + + + Enable printings again PrintingSelector - + Display Navigation Buttons + + + Printing Selector + + PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -6165,17 +9008,17 @@ Cockatrice will now reload the card database. PrintingSelectorCardSelectionWidget - + Previous Card in Deck - + Bulk Selection - + Next Card in Deck @@ -6183,124 +9026,140 @@ Cockatrice will now reload the card database. PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending + + PrintingSelectorPlaceholderWidget + + + Select a card to view its available printings + + + PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -6308,224 +9167,401 @@ Cockatrice will now reload the card database. QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) - + Maindeck - + Sideboard - + Tokens - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? + + + Enabling this feature will disable the use of the Printing Selector. + +You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. + +You will have to use the Set Manager, available through Card Database -> Manage Sets. + +Are you sure you would like to enable this feature? + + + + + Disabling this feature will enable the Printing Selector. + +You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. + +You can also use the Set Manager to adjust custom sort order for printings in the Printing Selector (other sort orders like alphabetical or release date are available). + +Are you sure you would like to disable this feature? + + + + + Confirm Change + + + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Name - + ID - + Upload time + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID - + Name Name - + Players - + Keep - + Time started - + Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -6533,70 +9569,78 @@ Cockatrice will now reload the card database. RoomSelector - + Rooms - + Joi&n - + Room - + Description - + Permissions - + Players - + Games + + SayMenu + + + S&ay + + + SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -6604,81 +9648,117 @@ Cockatrice will now reload the card database. SetsModel - + Enabled - + Set type - + Set code - + Long name - + Release date + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -6686,12 +9766,12 @@ Cockatrice will now reload the card database. ShortcutTreeView - + Action - + Shortcut @@ -6699,13 +9779,13 @@ Cockatrice will now reload the card database. ShortcutsSettings - + Your configuration file contained invalid shortcuts. Please check your shortcut settings! - + The following shortcuts have been set to default: @@ -6732,12 +9812,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -6745,27 +9825,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings Cap'n's orders for t' shanties - + Master volume @@ -6773,48 +9853,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -6822,45 +9902,204 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -6978,56 +10217,227 @@ Please check your shortcut settings! + + TabArchidekt + + + + + Desc. + + + + + + AND + + + + + + Require ALL selected colors + + + + + + Deck name... + + + + + + Owner... + + + + + + Packages + + + + + + Advanced Filters + + + + + Bracket: + + + + + + Any + + + + + + Contains card... + + + + + + Commander... + + + + + + Tag... + + + + + + Deck Size + + + + + Cards: + + + + + + Asc. + + + + + Sort by: + + + + + Filter by: + + + + + Display Settings + + + + + + Search + + + + + + Formats + + + + + Archidekt: + + + + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor - + Card Info - + Deck - + Filters - + &View - + + Card Database + + + + Printing - - - - + Visible - - - - + Floating - + Reset layout - + Deck: %1 Flagship: %1 @@ -7035,61 +10445,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - - - - + Visible - - - - + Floating - + Reset layout @@ -7097,22 +10495,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7120,138 +10518,236 @@ Please check your shortcut settings! TabDeckStorage - + Local file system - + Server deck storage O'er ship's flagship port - - + + Open in deck editor Open in dock - + Rename deck or folder - + Upload deck - + Download deck - - - - + + + + New folder - - + + Delete - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name Enter flagship name - + This decklist does not have a name. Please enter a name: - + Unnamed deck Unnamed flagship - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: - + Deck Storage @@ -7259,20 +10755,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -7285,32 +11032,32 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search - + EDHRec: @@ -7318,197 +11065,207 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + + Invite + + + + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases - + &Game - + Next &phase - + Next phase with &action - + Next &turn - + Reverse turn order - + &Remove all local arrows - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede - + &Leave game - + C&lose replay - + &Focus Chat - + &Say: - + Selected cards - + &View - - - - + Visible - - - - + Floating - + Reset layout - + Concede - + Are you sure you want to concede this game? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game - + Are you sure you want to leave this game? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -7516,7 +11273,7 @@ Please enter a name: TabHome - + Home @@ -7529,152 +11286,152 @@ Please enter a name: - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs - + Chat Logs - - + + Error - + You must select at least one filter. - + You have to select a valid number of days to locate. - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -7682,218 +11439,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat - + &Leave - + %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. - + %1 has joined the server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -7903,50 +12000,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - + &Say: - + Chat - + &Room - + &Leave room - + &Clear chat - + Chat Settings... Cap'n's orders for t' chat... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. @@ -7954,35 +12479,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server - - - - + + + + Error - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -7990,92 +12515,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + + Archidekt + + + + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? - + There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8083,38 +12689,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8122,12 +12728,45 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay + + + Database Display + + Visual Database Display + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -8142,6 +12781,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -8160,42 +12858,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -8203,214 +12901,327 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Remove Banner Card + + + UserContextMenu - + User &details - + Private &chat - + Show this user's &games - - - Add to &buddy list - - - - - Remove from &buddy list - - - - - Add to &ignore list - - - - - Remove from &ignore list - - - Kick from &game + View this user's &public decks - Warn user + Add to &buddy list - View user's war&n history + Remove from &buddy list - Ban from &server + Add to &ignore list - View user's &ban history + Remove from &ignore list - &Promote user to moderator + Kick from &game - Dem&ote user from moderator + Report user - Promote user to &judge + Warn user - Demote user from judge + View user's war&n history + Ban from &server + + + + + View user's &ban history + + + + + &Promote user to moderator + + + + + Dem&ote user from moderator + + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - - - Error - - - - - This user does not exist. - - - - - You are being ignored by %1 and can't see their games. + + Investigate user + + + Error + + + + + This user does not exist. + + + + + You are being ignored by %1 and can't see their games. + + + + Could not get %1's games. - + %1's games - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - - - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -8418,48 +13229,48 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: - + Account Age: - + Edit - + Change password - + Change avatar - + Administrator - + Moderator - + Registered user - - + + Unregistered user @@ -8469,408 +13280,913 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Unknown - + The entered password does not match your account. - - - + + + + Information - + User information updated. - - - - - - - - - - + + + + + + + + + + + Error - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. - + Password changed. - + This server does not permit you to change your password. - + The new password is too short. - + The old password is incorrect. - + Avatar updated. - + This server does not permit you to update your avatar. - - An error occured while trying to updater your avatar. + + An error occured while trying to update your avatar. + + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + Invite + + + + + Invite to your game + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. UserInterfaceSettingsPage - + General interface settings - + &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + + Do not delete &arrows inside of subphases + + + + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - + + Show selection count during drag selection + + + + + Show total selection count + + + + + + Disabled + + + + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect - + Animation settings Cap'n's orders for t' things that be movin' - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget - Mode: Exact Match + Exact match - - Mode: Includes + + Includes - - Mode: Include/Exclude + + Include / Exclude + Mode: Includes - - Filter mode (AND/OR/NOT conjunctions of filters) + + How selected and unselected colors are combined in the filter VisualDatabaseDisplayFilterSaveLoadWidget - + + Search filter... + + + + Save Filter - + Save all currently applied filters to a file - + Enter filename... + + VisualDatabaseDisplayFilterToolbarWidget + + + Sort by + + + + + Filter by + + + + + Save and load filters + + + + + Filter by exact card name + + + + + Filter by card main-type + + + + + Filter by card sub-type + + + + + Filter by set + + + + + Filter by format legality + + + + + Save/Load + + + + + Name + + + + + Main Type + + + + + Sub Type + + + + + Sets + + + + + Formats + + + + + VisualDatabaseDisplayFormatLegalityFilterWidget + + + Show formats with at least: + + + + + cards + + + + + Do not display formats with less than this amount of cards in the database + + + + + Filter mode (AND/OR/NOT conjunctions of filters) + + + + + Mode: Exact Match + + + + + Mode: Includes + + + VisualDatabaseDisplayMainTypeFilterWidget - + + Show main types with at least: + + + + + cards + + + + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes VisualDatabaseDisplayNameFilterWidget - - - Filter by name... - - - - - Load from Deck - - - Apply all card names in currently loaded deck as exact match name filters + Filter by name... (Exact match) - Load from Clipboard + Load from Deck + Apply all card names in currently loaded deck as exact match name filters + + + + + Load from Clipboard + + + + Apply all card names in clipboard as exact match name filters @@ -8878,7 +14194,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -8886,19 +14202,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -8906,27 +14222,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + + Show sub types with at least: + + + + + cards + + + + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -8934,50 +14260,93 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + + + Visual + + + + Loading database ... - + Clear all filters - - Save and load filters + + Table + + + + + VisualDeckDisplayOptionsWidget + + + Group by: - - Filter by exact card name + + Change how cards are divided into categories/groups. - - Filter by card sub-type + + Sort by: - - Filter by set + + Click and drag to change the sort order within the groups + + + + + Configure how cards are sorted within their groups + + + + + + Toggle Layout: Overlap + + + + + Change how cards are displayed within zones (i.e. overlapped or fully visible.) + + + + + Toggle Layout: Flat + + + + + VisualDeckEditorPlaceholderWidget + + + Add cards using the search bar or database tab to have them appear here VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -8985,46 +14354,25 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - - Click and drag to change the sort order within the groups + + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter - - - Configure how cards are sorted within their groups - - - - - - Overlap Layout - - - - - Change how cards are displayed within zones (i.e. overlapped or fully visible.) - - - - - Flat Layout - - VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -9032,47 +14380,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + + Show Color Identity + + + + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -9080,7 +14438,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -9088,22 +14446,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -9111,17 +14469,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -9129,46 +14492,71 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets @@ -9192,11 +14580,6 @@ Please refrain from engaging in this activity or further actions may be taken ag Move selected set to the bottom Move selected fleet to the bottom - - - Search by set name, code, or type - - Default order @@ -9208,98 +14591,103 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Enable all sets Enable all fleets - + Disable all sets Disable all fleets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + + Search by set name, code, type, or release date + + + + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -9307,93 +14695,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing - + pile view - - i18n - - - English - English, arr! (Pirate English) - - main - + Connect on startup - + Debug to file @@ -9401,1005 +14781,1088 @@ Please refrain from engaging in this activity or further actions may be taken ag shortcutsTab - + Main Window - - + + Deck Editor - + Game Lobby - + Card Counters - + Player Counters - + Power and Toughness - + Game Phases - + Playing Area - + Move Selected Card - + View - + Move Top Card - + Move Bottom Card - + Gameplay - + Drawing - + Chat Room - + Game Window - + Load Deck from Clipboard - - + + Replays - + Tabs - + Check for Card Updates... - + Connect... - + Disconnect - + Exit - + Full screen - + Register... - + Settings... - + Start a Local Game... - + Watch Replay... - + Analyze Deck (deckstats.net) - + Analyze Deck (tappedout.net) - + Clear All Filters - + Clear Selected Filter - + Close - + Remove Card - + Manage Sets... - + Edit Custom Tokens... - + Export Deck (decklist.org) - + Export Deck (decklist.xyz) - + Add Card - + Load Deck... - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter - + Show Status Bar - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap - + Upkeep - + Draw - + First Main Phase - + Start Combat - + Attack - + Block - + Damage - + End Combat - + Second Main Phase - + End - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - - Toggle Untap + + Toggle Skip Untapping + Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + + Play Card, Face Down + + + + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - - + + Reveal Selected Cards to All Players + + + + + Bottom of Library - - - - + + + + Exile - - - - + + + + Graveyard - - + + Report Queue + + + + + Moderation + + + + + + Hand - - + + Top of Library - - - + + Battlefield, Face Down - + Battlefield - - Sort Hand - - - - + Library - + Sideboard - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + + Graveyard (Multiple), Face Down + + + + + Exile (Multiple) - + + + Exile (Multiple), Face Down + + + + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede Parley - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan - + + Mulligan (Same hand size) + + + + + Mulligan (Hand size - 1) + + + + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + + Sort Hand by Name + + + + + Sort Hand by Type + + + + + Sort Hand by Mana Value + + + + + Reveal Hand to All Players + + + + + Reveal Random Card to All Players + + + + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/oracle/translations/oracle_en@pirate.ts b/oracle/translations/oracle_en@pirate.ts index 13a57750b..a31b9d332 100644 --- a/oracle/translations/oracle_en@pirate.ts +++ b/oracle/translations/oracle_en@pirate.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. - + Interface language: - + Version: @@ -25,177 +25,206 @@ LoadSetsPage - + Source selection - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + Load sets file - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Cap'n? Thar be a problem - + The provided URL is not valid. - - Downloading (0MB) - - - - + Please choose a file. - + Cannot open file '%1'. - - Downloading (%1MB) - - - - + Network error: %1. Cap'n? Thar be a problem wi' t' smoke signals: %1. - + Parsing file - + Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. - + Zip extraction failed: %1. - + Sorry, this version of Oracle does not support zipped files. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database - + XML; spoiler database (*.xml) - + + spoiler + + + + Spoilers import - + Please specify a compatible source for spoiler data. - + Download URL: - + + Local file: + + + + Restore default URL - + + Choose file... + + + + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -203,42 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + + tokens + + + + Tokens import - + Please specify a compatible source for token data. - + Download URL: - + + Local file: + + + + Restore default URL - + + Choose file... + + + + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -246,7 +290,7 @@ OracleImporter - + Dummy set containing tokens @@ -262,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -285,73 +329,76 @@ SaveSetsPage - - + + Error Cap'n? Thar be a problem - + + No set has been imported. - + Sets imported - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. - + + %1: %2 cards imported - + Save card database - + XML; card database (*.xml) - + The file could not be saved to %1 @@ -359,34 +406,57 @@ SimpleDownloadFilePage - - - + + Load %1 file + + + + + %1 file (%1) + + + + + + + + Error - + The provided URL is not valid: - - Downloading (0MB) + + Downloading (0 MB) + Downloading (0MB) - + + Please choose a file. + + + + + Cannot open file '%1'. + + + + Downloading (%1MB) - + Network error: %1. - + The file could not be saved to %1 @@ -535,7 +605,7 @@ i18n - + English English, arr! (Pirate English) @@ -543,12 +613,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode From cc35258cdbfa7e287a5ebfcfc0e2e24e649767e9 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:45 +0200 Subject: [PATCH 065/247] Updates for project Cockatrice and language pl (#7560) * Translate oracle_en@source.ts in pl [Manual Sync] 82% of minimum 5% translated source file: 'oracle_en@source.ts' on 'pl'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in pl [Manual Sync] 41% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'pl'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_pl.ts | 7446 ++++++++++++++++------ oracle/translations/oracle_pl.ts | 204 +- 2 files changed, 5626 insertions(+), 2024 deletions(-) diff --git a/cockatrice/translations/cockatrice_pl.ts b/cockatrice/translations/cockatrice_pl.ts index 29b7b3896..06d6d6e3d 100644 --- a/cockatrice/translations/cockatrice_pl.ts +++ b/cockatrice/translations/cockatrice_pl.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Ustaw znacznik… @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Ustaw znacznik - + New value for counter '%1': Nowa wartość dla znacznika '%1' @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes Aktualności - + Admin Notes for %1 Notatki Administracyjne dla %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error Błąd - + Could not create themes directory at '%1'. Nie można stworzyć folderu motywów w '%1'. - + Theme settings Ustawienia motywu - + Current theme: Obecny motyw: - + Open themes folder Otwórz folder motywów - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings Ustawienia menu - + Show keyboard shortcuts in right-click menus Pokazuj skróty klawiszowe w menu kontekstowym - + Show game filter toolbar above list in room tab - + Card rendering Rendering kart - + Display card names on cards having a picture Wyświetl nazwy kart na kartach z obrazkami - + Auto-Rotate cards with sideways layout Automatycznie obracaj karty horyzontalne - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector Podbijaj dodatki z których karty są w talii. - + Scale cards on mouse over Skaluj karty po najechaniu kursorem myszy - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand Minimalny procent zachodzenia kart na siebie na stosie oraz w ręce pionowej - + Maximum initial height for card view window: Maksymalna podstawowa wysokość okna podglądu karty - - + + rows rzędy - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Układ ręki - + Display hand horizontally (wastes space) Wyświetl karty na ręce poziomo (marnuje miejsce) - + Enable left justification Wyrównuj karty do lewej strony pola - + Table grid layout Układ stołu - + Invert vertical coordinate Odwróć współrzędne pionowe - + Minimum player count for multi-column layout: Minimalna liczba graczy dla widoku wielokolumnowego - + Maximum font size for information displayed on cards: Maksymalny rozmiar czcionki dla informacji wyświetlanych na kartach @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name zbanuj nazwę &użytkownika - + ban &IP address zbanuj adres &IP - + ban client I&D ID zablokowanego konta - + Ban type Rodzaj bana - + &permanent ban ban &permanentny - + &temporary ban ban &tymczasowy - + &Days: &Dni: - + &Hours: &Godziny: - + &Minutes: &Minuty: - + Duration of the ban Czas trwania bana - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Podaj przyczynę bana. Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowanej osoby. - + Please enter the reason for the ban that will be visible to the banned person. Podaj przyczynę bana do wglądu dla banowanej osoby. - + Redact all messages from this user in all rooms Redaguj wiadomości od tego użytkownika we wszystkich pokojach - + &OK &OK - + &Cancel &Anuluj - + Ban user from server Zbanuj użytkownika z serwera - - - - + + + + Error Błąd - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Aby ustawić bana, musisz wybrać nazwę, IP, ID klienta lub dowolną kombinację tej trójki. - + You must have a value in the name ban when selecting the name ban checkbox. Pole imienia musi być wypełnione dla opcji ban imienia. - + You must have a value in the ip ban when selecting the ip ban checkbox. Pole ip musi być wypełnione dla opcji ban ip. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Pole id klienta musi być wypełnione dla opcji ban id klienta. @@ -499,22 +634,22 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan BetaReleaseChannel - + Beta - + No reply received from the release update server. Brak odpowiedzi z serwera uaktualnień wersji. - + Invalid reply received from the release update server. Nieprawidłowa odpowiedź serwera uaktualnień wersji. - + No reply received from the file update server. Brak odpowiedzi z serwera uaktualnień plików. @@ -522,7 +657,20 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardDatabaseModel - + Name Nazwa - + Sets Dodatki - + Mana cost Koszt many - + Card type Typ karty - + P/T S/W - + Color(s) Kolor(y) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardInfoFrameWidget - + Image Ilustracja - + Description Opis - + Both Karta + opis - + View transformation @@ -744,22 +1036,22 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardInfoTextWidget - + Name: Nazwa: - + Set: - + Collector Number: - + Related cards: Powiązane karty: - + Unknown card: Nieznana karta: @@ -795,129 +1087,129 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardSizeWidget - + Card Size Rozmiar Karty @@ -1078,6 +1370,24 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Uaktualnij Spoilery - + Success Sukces - + Download URLs have been reset. Pobrane URL zostały zresetowane. - + Add URL Dodaj URL - - + + URL: URL: - - + + Edit URL Edytuj URL - + Add New URL Dodaj Nowy URL - + Remove URL Usuń URL - + Updating... Trwa aktualizacja... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Wybierz ścieżkę - + URL Download Priority Priorytet Pobierania URL - + Spoilers Spoilery - + Download Spoilers Automatically Pobierz spoilery automatycznie - + Spoiler Location: Lokalizacja spoilera - + Last Change Ostatnia zmiana - + Spoilers download automatically on launch Spoilery są pobierane automatycznie w czasie premiery. - + Press the button to manually update without relaunching Wciśnij przycisk żeby ręcznie uaktualnić bez restartu. - + Do not close settings until manual update is complete Nie zamykaj ustawień dopóki aktualizacja manualna się nie skończy - + Download card pictures on the fly Pobierz ilustracje kart w locie - + How to add a custom URL Jak dodać niestandardowe URL - + Reset Download URLs Resetuj Pobieranie URLi @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Ilość - + Set Dodatek - + Number Ilość - + Provider ID Provider ID - + Card Karta @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Wczytaj talię... - + Load remote deck... Wczytaj zdalną talię... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start Gotowe do rozpoczęcia - + Force start Wymuś rozpoczęcie - + Sideboard unlocked Biblioteka poboczna odblokowana - + Sideboard locked Biblioteka poboczna zablokowana - - + + Error Błąd - + The selected file could not be loaded. Wybrany plik nie mógł zostać wczytany. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. Pobieranie... - + Known Hosts Znane Hosty - + Delete the currently selected saved server Usuń zaznaczony zapisany serwer - + Refresh the server list with known public servers Odśwież listę serwerów ze znanymi serwerami publicznymi - + New Host Nowy Host - + Name: Nazwa: - + &Host: &Host: - + &Port: &Port: - + Player &name: &Nazwa gracza: - + P&assword: H&asło: - + &Save password Zapi&sz hasło - + A&uto connect Połącz a&utomatycznie - + Automatically connect to the most recent login when Cockatrice opens Automatycznie połącz ostatnim loginem przy otwarciu Cockatrice - + If you have any trouble connecting or registering then contact the server staff for help! Jeżeli masz problem z połączeniem lub rejestracją, skontaktuj się z obsługą serwera! - - + + Webpage Strona internetowa - + Reset Password Zresetuj hasło - + Forgot password? Zapomniałeś hasła? - + &Connect &Połącz - + Server Serwer - + Login Login - + Server Contact Kontakt z Serwerem - + Connect to Server Połącz z Serwerem - + Server URL Serwer URL - + Communication Port Port Komunikacyjny - + Unique Server Name Unikalna Nazwa Serwera - + Connection Warning Ostrzeżenie o połączeniu - + You need to name your new connection profile. Musisz nazwać swój nowy profil połączenia - + Connect Warning Ostrzeżenie połączenia - + The player name can't be empty. Nazwa użytkownika nie może być pusta @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Zapa&miętaj ustawienia - + &Description: &Opis: - + P&layers: &Liczba graczy: - + General Ogólne - + Game type Format - + &Password: &Hasło: - + Only &buddies can join Tylko dla zn&ajomych - + Only &registered users can join Tylko dla &zarejestrowanych użytkowników - + Joining restrictions Ograniczenia dostępu - + &Spectators can watch &Widzowie dozwoleni - + Spectators &need a password to watch Widzowie muszą z&nać hasło - + Spectators can &chat Widzowie mogą korzystać z &czatu - + Spectators can see &hands Widzowie mogą oglądać &ręce - + Create game as spectator Stwórz grę jako widz - + Spectators Widzowie - + Starting life total: Początkowa ilość życia: - + Open decklists in lobby - + Create game as judge - + Game setup options Opcje tworzenia gry - + &Clear &Wyczyść - + Create game Stwórz grę - + Game information Informacje o grze - + Error Błąd - + Server error. Błąd serwera. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nazwa: - + Token Token - + C&olor: &Kolor: - + white biały - + blue niebieski - + black czarny - + red czerwony - + green zielony - + multicolor wielokolorowy - + colorless bezkolorowy - + &P/T: &S/W: - + &Annotation: &Adnotacja: - + &Destroy token when it leaves the table &Zniszcz tokena kiedy opuści stół - + Create face-down (Only hides name) - + Token data Dane tokena - + Show &all tokens Pok&azuj wszystkie tokeny - + Show tokens from this &deck Pokazuj tokeny z tej &talii - + Choose token from list Wybierz token z listy: - + Create token Tworzenie tokena @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Nie wybrano obrazu. @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. By usunąć twojego bieżącego awatara potwierdź bez wybierania nowego obrazka. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Przeglądaj... - + Change avatar Zmień awatar - + Open Image Otwórz plik obrazu - + Image Files (*.png *.jpg *.bmp) Obrazy (*.png *.jpg *.bmp) - + Invalid image chosen. Nieprawidłowy obraz. @@ -2836,38 +3454,38 @@ By usunąć twojego bieżącego awatara potwierdź bez wybierania nowego obrazka DlgEditPassword - + Old password: Bieżące hasło: - + New password: Nowe hasło: - + Confirm new password: Potwierdź nowe hasło: - + Change password Zmień hasło - - + + Error Błąd - + Your password is too short. Twoje hasło jest za krótkie. - + The new passwords don't match. Hasła nie są zgodne. @@ -2875,93 +3493,93 @@ By usunąć twojego bieżącego awatara potwierdź bez wybierania nowego obrazka DlgEditTokens - + &Name: &Nazwa: - + C&olor: K&olor: - + white biały - + blue niebieski - + black czarny - + red czerwony - + green zielony - + multicolor multikolorowy - + colorless bezkolorowy - + &P/T: &S/W: - + &Annotation: &Adnotacja: - + Token data Dane tokena - - + + Add token Dodaj token - + Remove token Usuń token - + Edit custom tokens Edytuj niestandardowe żetony - + Please enter the name of the token: Wprowadź nazwę tokena: - + Error Błąd - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Wybrana nazwa koliduje z istniejącą kartą lub tokenem. @@ -3040,7 +3658,8 @@ Upewnij się że aktywowano 'Token' w oknie "Zarządzaj dodatkami - Hide games that have started + Hide started games + Hide games that have started @@ -3076,8 +3695,9 @@ Upewnij się że aktywowano 'Token' w oknie "Zarządzaj dodatkami - &Creator name: - Założy&ciel gry + &Host name: + &Creator name: + @@ -3143,52 +3763,52 @@ Upewnij się że aktywowano 'Token' w oknie "Zarządzaj dodatkami DlgForgotPasswordChallenge - + Reset Password Challenge Warning Ostrzeżenie w procesie przypominania hasła - + A problem has occurred. Please try to request a new password again. Wystąpił problem. Poproś o nowe hasło ponownie. - + Enter the information of the server and the account you'd like to request a new password for. Wpisz informacje serwera oraz konta, do którego chciał(a)byś stworzyć nowe hasło. - + &Host: &Host: - + &Port: &Port: - + Player &name: &Nazwa gracza: - + Email: Email: - + Reset Password Challenge Proces przypominania hasła - + Reset Password Challenge Error Błąd w procesie przypominania hasła - + The email address can't be empty. Adres email nie może być pusty. @@ -3196,37 +3816,37 @@ Upewnij się że aktywowano 'Token' w oknie "Zarządzaj dodatkami DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Wpisz informacje serwera na którym chciał(a)byś stworzyć nowe hasło. - + &Host: &Host: - + &Port: &Port: - + Player &name: &Nazwa gracza: - + Reset Password Request Żądanie Resetu Hasła - + Reset Password Error Błąd Resetu Hasła - + The player name can't be empty. Nazwa gracza nie może być pusta. @@ -3234,94 +3854,127 @@ Upewnij się że aktywowano 'Token' w oknie "Zarządzaj dodatkami DlgForgotPasswordReset - + Reset Password Warning Ostrzeżenie Resetu Hasła - + A problem has occurred. Please try to request a new password again. Wystąpił problem. Poproś o nowe hasło ponownie. - + Enter the received token and the new password in order to set your new password. Wpisz otrzymany token oraz nowe hasło aby je ustawić. - + &Host: &Host: - + &Port: &Port: - + Player &name: &Nazwa gracza: - + Token: Token: - - + + New Password: Nowe Hasło: - + Reset Password Zresetuj hasło - + The player name can't be empty. Nazwa gracza nie może być pusta. - - - - + + + + Reset Password Error Błąd Resetu Hasła - + The token can't be empty. Token nie może być pusty. - + The new password can't be empty. Nowe hasło nie może być puste. - + Error Błąd - + Your password is too short. Twoje hasło jest za krótkie. - + The passwords do not match. Hasła nie są zgodne. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3405,166 +4058,567 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): Nazwa karty (lub wyrażenie wyszukiwania): - + Number of hits: Ilość trafień: - + Auto play hits Automatycznie graj trafienia - + Put top cards on stack until... Kładź karty z wierzchu na stosie dopóki... - + No cards matching the search expression exists in the card database. Proceed anyways? W bazie kart nie znaleziono kart które spełniają twoje wyrażenie. Kontynuować mimo to? - + Cockatrice Cockatrice - + Invalid filter Nieprawidłowy filtr + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Wpisz swoje informacje oraz informacje serwera do którego chciałbyś się zarejestrować. Twój email zostanie użyty aby zweryfikować twoje konto. - + &Host: &Host: - + &Port: &Port: - + Player &name: &Nazwa gracza: - + P&assword: H&asło: - + Password (again): Hasło (ponownie): - + Email: Email: - + Email (again): Email (ponownie): - + Country: Kraj: - + Undefined Nie wiadomo - + Real name: Prawdziwe imię: - + Register to server Zarejestruj się - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Ostrzeżenie rejestracji - + Your password is too short. Twoje hasło jest za krótkie. - + Your passwords do not match, please try again. Wpisane hasła nie są zgodne. Spróbuj ponownie. - + Your email addresses do not match, please try again. Wpisane adresy mailowe nie są zgodne. Spróbuj ponownie. - + The player name can't be empty. Nazwa użytkownika nie może być pusta + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3630,46 +4684,12 @@ Twój email zostanie użyty aby zweryfikować twoje konto. DlgSettings - + Unknown Error loading card database W trakcie wczytywania bazy kart wystąpił nieznany błąd - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Nieprawidłowa baza kart. - -Cockatrice nie może działać bez prawidłowej bazy kart. - -Może istnieć potrzeba uruchomienia Oracle w celu uaktualnienia bazy kart. - -Czy chcesz zmienić ustawienia położenia bazy kart? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Twoja baza kart jest przestarzała. - -To może spowodować błędy we wczytywaniu kart i ich ilustracji. - -Zwykle można temu zaradzić poprzez uruchomienie narzędzia Oracle i uaktualnienie bazy kart. - -Czy chcesz zmienić ustawienia położenia bazy kart? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3682,7 +4702,7 @@ Proszę wypełnić zgłoszenie błędu pod adresem https://github.com/Cockatrice Czy chcesz zmienić ustawienia położenia bazy kart? - + File Error loading your card database. Would you like to change your database location setting? @@ -3691,7 +4711,7 @@ Would you like to change your database location setting? Czy chcesz ustawić nową lokalizację bazy kart? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3700,7 +4720,7 @@ Would you like to change your database location setting? Czy chcesz ustawić nową lokalizację bazy kart? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3713,104 +4733,235 @@ Proszę wypełnić zgłoszenie błędu pod adresem https://github.com/Cockatrice Czy chcesz zmienić ustawienia położenia bazy kart? - - - + + + Error Błąd - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Ścieżka dostępu do twojego katalogu z taliami jest nieprawidłowa. Czy chcesz wrócić i ustawić poprawną ścieżkę? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Ścieżka dostępu do twojego katalogu z obrazkami jest nieprawidłowa. Czy chcesz wrócić i ustawić poprawną ścieżkę? - + Settings Ustawienia - + + Search settings... + + + + + OK + + + + General Ogólne - + Appearance Wygląd - + User Interface Interfejs - + Storage - + Card Sources Źródło Karty - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Czat - + Sound Dźwięk - + Shortcuts Skróty + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3818,17 +4969,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Następna - + Previous Poprzednia - + Tip of the Day Wskazówka na Dziś @@ -3836,165 +4987,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel Kanał obecnej wersji - + Reinstall Przeinstaluj - + Cancel Download Anuluj Pobieranie - + Open Download Page Otwórz stronę pobierań - + Check for Client Updates Sprawdź aktualizacje klienta - - - - + + + + Error Błąd - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice nie wspiera SSL, dlatego automatyczne pobieranie uaktualnień nie jest możliwe! Odwiedź stronę pobierania żeby uaktualnić ręcznie. - + Downloading update: %1 - + Checking for updates... Sprawdzam aktualizacje... - + Finished checking for updates Skończyłem sprawdzać aktualizacje - + No Update Available Brak nowych aktualizacji - + Cockatrice is up to date! Cockatrice jest aktualny! - + You are already running the latest version available in the chosen release channel. Używasz obecnie najnowszej dostępnęj wersji w wybranym kanale wydania - + Current version Obecna wersja - + Selected release channel Wybierz kanał wydania - - + + Update Available Dostępna aktualizacja - - + + A new version of Cockatrice is available! Nowa wersja Cockatrice jest dostępna! - - + + New version Nowa wersja - - + + Released Wydania - - + + Changelog Zmiany - + Do you want to update now? Chcesz teraz zaktualizować? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Błąd aktualizacji - + An error occurred while checking for updates: Wystąpił błąd podczas sprawdzania aktualizacji: - + An error occurred while downloading an update: Wystąpił błąd podczas pobierania aktualizacji: - + Installing... Instaluję... - + Cockatrice is unable to open the installer. Cockatrice nie może otworzyć instalatora. - + Try to update manually by closing Cockatrice and running the installer. Spróbuj zaktualizować ręcznie poprzez zamknięcie Cockatrice i włączenie instalatora. - + Download location Lokalizacja pobierania @@ -4002,17 +5163,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing Wyczyść logi przy zamykaniu - + Copy to clipboard - + Debug Log Log Debugera @@ -4250,7 +5411,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4258,7 +5419,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Podaj swój filtr @@ -4266,45 +5427,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4312,140 +5516,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Błąd - + Please join the appropriate room first. Proszę najpierw dołączyć do właściwego pokoju. - + Wrong password. Nieprawidłowe hasło. - + Spectators are not allowed in this game. Ta rozgrywka nie dopuszcza widzów. - + The game is already full. Ta rozgrywka jest już pełna. - + The game does not exist any more. Ta rozgrywka już nie istnieje. - + This game is only open to registered users. Ta rozgrywka jest dostępna tylko dla zarejestrowanych użytkowników. - + This game is only open to its creator's buddies. Ta rozgrywka jest dostępna tylko dla znajomych jej twórcy. - + You are being ignored by the creator of this game. Twórca tej rozgrywki cię ignoruje. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game Dołącz do gry - - Password: - Hasło: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Proszę najpierw dołączyć do odpowiedniego pokoju. - + &Filter games &Filtr rozgrywek - + C&lear filter Usuń fi&ltr - + C&reate Utwó&rz - + &Join &Dołącz - + Join as judge - + J&oin as spectator D&ołącz jako widz - + Join as judge spectator - + Games shown: %1 / %2 Gra pokazana: %1 / %2 - + Games Gry @@ -4486,105 +5706,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 dzień - + %1%2 hr short age in hours %1%2 godz%1%2 godz%1%2 godz%1%2 godz - + new Nowa - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min%1%2 min - + password hasło - + buddies only tylko znajomi - + reg. users only tylko zarejestrowani użytkownicy - + open decklists - - + + can chat mogą rozmawiać - + see hands widzą ręce - + can see hands widzą ręce - + not allowed niedozwoleni - + Room Pokój - + Age Wiek - + Description Opis - - Creator - Twórca + + Host + - + Type Typ - + Restrictions Ograniczenia - + Players Gracze - + Spectators Widzowie @@ -4592,158 +5812,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Resetuj wszystkie ścieżki - + All paths have been reset Wszystkie ścieżki zostały zresetowane - - - - - - - + + + + English + Polski (Polish) + + + + + + + + + Choose path Wybierz ścieżkę - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Język: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Scieżki (edytowanie wyłączone w trybie przenośnym) - + Paths Ścieżki - + How to help with translations - + Decks directory: Katalog z taliami: - + Filters directory: - + Replays directory: Katalog z powtórkami: - + Pictures directory: Katalog z obrazkami: - + Card database: Baza kart: - + Custom database directory: Niestandardowy folder z bazą: - + Token database: Baza tokenów: - + Update channel Aktualizuj kanał - + Check for client updates on startup Sprawdzaj aktualizacje klienta przy uruchomieniu - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Powiadom jeśli właściwości wspierane przez serwer, brakują w kliencie - + Automatically run Oracle when running a new version of Cockatrice Automatycznie uruchom Oracle kiedy uruchomiona jest nowa wersja Cockatrice - + Show tips on startup Pokazuj wskazówki po uruchomieniu - + Last update check on %1 (%2 days ago) @@ -4751,47 +6088,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4885,59 +6222,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5155,273 +6786,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Gracz %1 - + Load replay Wczytaj powtórkę - + About Cockatrice O Cockatrice - + Version Wersja - + Cockatrice Webpage Oficjalna strona Cocatrice - + Project Manager: Menadżer projektu: - + Past Project Managers: Poprzedni menadżerowie projektu: - + Developers: Twórcy: - + Our Developers Lista współpracowników - + Help Develop! Dołącz do grona twórców! - + Translators: Tłumacze: - + Our Translators Nasi Tłumacze: - + Help Translate! Pomóż w tłumaczeniu! - + Support: Wsparcie: - + Report an Issue Zgłoś problem - + Troubleshooting Rozwiązywanie problemów - + F.A.Q. Najczęściej zadawane pytania - - + + Error Błąd - + &Connect... &Połącz… - + &Disconnect &Rozłącz - + Start &local game... Rozpocznij &lokalną grę… - + &Watch replay... &Obejrzyj powtórkę… - + &Full screen Pełny ekra&n - + &Register to server... &Zarejestruj się... - + &Restore password... &Przywróć hasło... - + &Settings... &Ustawienia… - + &Exit &Wyjdź - + A&ctions Ak&cje - + &Cockatrice &Cockatrice - + C&ard Database B&aza kart - + &Manage sets... &Zarządzaj dodatkami... - + Edit custom &tokens... Edytuj niestandardowe %tokens... - + Open custom image folder Otwórz folder ze spersonalizowanymi obrazkami - + Open custom sets folder Otwórz folder ze spersonalizowanymi setami - + Add custom sets/cards Dodaj spersonalizowane sety/karty - + Reload card database Przeładuj bazę kart - + Tabs - + &Help Po&moc - + &About Cockatrice &O Cockatrice - + &Tip of the Day &Wskazówka na Dziś - + Check for Client Updates Sprawdź uaktualnienia klienta - + Check for Card Updates... Uaktualnij bazę kart... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log Pokaż log &debugowania - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide Pokaż/Ukryj - + + Sign-in cancelled + + + + New Version Nowa Wersja - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Gratulacje z powodu aktualizacji do Cockatrice %1! Oracle uruchomi się teraz aby zaktualizować bazę danych kart. - + Cockatrice installed Cockatrice zainstalowany - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Gratulacje z powodu instalacji Cockatrice %1! Oracle uruchomi się teraz aby zainstalować początkowe bazy danych kart. - + + Are you sure? + Czy na pewno? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database Baza kart - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5430,29 +7082,29 @@ Czy chcesz teraz uaktualnić bazę kart? Jeżeli nie masz pewności lub jesteś nowym użytkownikiem, wybierz "Tak" - - + + Yes Tak - - + + No Nie - + Open settings Otwórz ustawienia - + New sets found Znaleziono nowe dodatki - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5467,22 +7119,22 @@ Kody dodatków: %1 Czy chcesz je teraz uaktywnić? - + Yes, always enable - + View sets Pokaż dodatki - + Welcome Witaj - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5491,89 +7143,89 @@ Wszystkie dodatki w bazie kart zostały aktywowane. Przeczytaj więcej na temat zmiany kolejności czy wyłączania poszczególnych dodatków i tym podobnych w oknie "Zarządzaj Dodatkami". - + Information Informacja - + A card database update is already running. Aktualizacja bazy kart jest w trakcie - + Unable to run the card database updater: Nie można uruchomić aktualizacji bazy kart: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Wczytaj dodatki/karty - + Selected file cannot be found. Wybrany plik nie został znaleziony. - + You can only import XML databases at this time. Możesz zaimportować jedynie bazy danych XML w tym momencie. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Nowe dodatki/karty dodane pomyślnie. Cockatrice ponownie załaduje teraz bazę danych. - + Sets/cards failed to import. Dodatki/karty nie mogły być zaimportowane. @@ -5778,615 +7430,615 @@ Cockatrice ponownie załaduje teraz bazę danych. MessageLogWidget - + from play z gry - + from their graveyard z jego grobu - + from exile ze strefy wygnania - + from their hand z jego ręki - + the top card of %1's library karta z wierzchu biblioteki gracza %1 - + the top card of their library pierwsza karta jego biblioteki - + from the top of %1's library z wierzchu biblioteki gracza %1 - + from the top of their library z wierzchu jego biblioteki - + the bottom card of %1's library karta z wierzchu biblioteki gracza %1 - + the bottom card of their library ostatnia karta jego biblioteki - + from the bottom of %1's library z dna biblioteki gracza %1 - + from the bottom of their library z dna jego biblioteki - + from %1's library z biblioteki gracza %1 - + from their library z jego biblioteki - + from sideboard z talii pobocznej - + from the stack ze stosu - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 od teraz gra z odwróconą kartą z wierzchu %2. - + %1 is not revealing the top card %2 any longer. %1 już nie gra z odwróconą kartą z wierzchu %2. - + %1 can now look at top card %2 at any time. %1 może teraz patrzeć na kartę z wierzchu %2 w każdej chwili. - + %1 no longer can look at top card %2 at any time. %1 nie może już patrzeć na kartę z wierzchu %2 w każdej chwili. - + %1 attaches %2 to %3's %4. %1 przyłącza %2 do %4 %3 - + %1 has conceded the game. %1 poddał grę. - + %1 has unconceded the game. %1 nie poddał gry. - + %1 has restored connection to the game. %1 odzyskał połączenie z grą. - + %1 has lost connection to the game. %1 utracił połączenie z grą. - + %1 points from their %2 to themselves. %1 wskazuje z jego karty %2 na samego siebie. - + %1 points from their %2 to %3. %1 wskazuje z jego karty %2 na kartę %3. - + %1 points from %2's %3 to themselves. %1 wskazuje z karty gracza %2 %3 na samą siebie. - + %1 points from %2's %3 to %4. %1 wskazuje z karty gracza %2 %3 na kartę %4. - + %1 points from their %2 to their %3. %1 wskazuje z jego karty %2 na jego kartę %3. - + %1 points from their %2 to %3's %4. %1 wskazuje z jego karty %2 na karty %3's %4. - + %1 points from %2's %3 to their own %4. %1 wskazuje z kart %2's %3 na swoją kartę %4. - + %1 points from %2's %3 to %4's %5. %1 wskazuje z karty gracza %2 %3 na kartę gracza %4 (%5). - + %1 creates a face down token. - + %1 creates token: %2%3. %1 stworzył token: %2%3. - + %1 has loaded a deck (%2). Gracz %1 wczytał talię (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). Gracz %1 wczytał talię oraz %2 kart talii pobocznej (hash: %3). - + %1 destroys %2. %1 niszczy %2. - + a card kartę - + %1 gives %2 control over %3. %1 oddaje %3 pod kontrolę gracza %2. - + %1 puts %2 into play%3 face down. %1 zagrywa kartę %2 na pole bitwy%3 koszulką do góry. - + %1 puts %2 into play%3. %1 zagrywa kartę %2 na pole bitwy %3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 kładzie %2 %3 na cmentarz. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 przenosi kartę %2 do strefy wygnania %3. - + %1 moves %2%3 to their hand. %1 bierze %2 %3 do ręki. - + %1 puts %2%3 into their library. %1 wkłada %2 %3 do biblioteki. - + %1 puts %2%3 onto the bottom of their library. %1 przenosi %2 %3 na dno swojej biblioteki. - + %1 puts %2%3 on top of their library. %1 przenosi %2 %3 na wierzch swojej bibliotek - + %1 puts %2%3 into their library %4 cards from the top. %1 kładzie %2 %3 na jego biblioteke %4 kart z góry. - + %1 moves %2%3 to sideboard. %1 przenosi kartę %2 %3 do talii pobocznej. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 zagrywa kartę %2 %3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 próbuje dobrać z pustej biblioteki - + %1 draws %2 card(s). %1 dobiera %2 kartę.%1 dobiera %2 kart.%1 dobiera %2 kart.%1 dobiera %2 kart. - + %1 is looking at %2. %1 przegląda %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1 kładzie %2 grzbietem do góry. - + %1 turns %2 face-up. %1 kładzie %2 grzbietem do dołu. - + The game has been closed. Gra została zakończona. - + The game has started. Gra została rozpoczęta. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 dołączył do gry. - + %1 is now watching the game. %1 obserwuje grę. - + You have been kicked out of the game. Wyrzucono cię z gry. - + %1 has left the game (%2). %1 opuścił grę (%2). - + %1 is not watching the game any more (%2). %1 przestał oglądać grę (%2). - + %1 is not ready to start the game any more. %1 nie jest już gotowy do rozpoczęcia gry. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 tasuje swoją talie i dobiera nową rękę z %2 kartą.%1 tasuje swoją talie i dobiera nową rękę z %2 kartami.%1 tasuje swoją talie i dobiera nową rękę z %2 kartami.%1 tasuje swoją talie i dobiera nową rękę z %2 kartami. - + %1 shuffles their deck and draws a new hand. %1 tasuje swoją talie i dobiera nową rękę. - + You are watching a replay of game #%1. Oglądasz powtórkę gry #%1. - + %1 is ready to start the game. %1 jest gotowy do rozpoczęcia gry. - + cards an unknown amount of cards karty - + %1 card(s) a card for singular, %1 cards for plural jedną kartę%1 kart%1 kart%1 kart - + %1 lends %2 to %3. %1 pożycza %2 graczowi %3. - + %1 reveals %2 to %3. %1 pokazuję kartę %2 graczowi %3. - + %1 reveals %2. %1 pokazuję kartę %2. - + %1 randomly reveals %2%3 to %4. %1 pokazuje losowo kartę %2 %3 graczowi %4. - + %1 randomly reveals %2%3. %1 pokazuje losowo kartę %2 %3. - + %1 peeks at face down card #%2. %1 spogląda na kartę odwróconą koszulką do góry #%2. - + %1 peeks at face down card #%2: %3. %1 spogląda na kartę odwróconą koszulką do góry #%2: %3. - + %1 reveals %2%3 to %4. %1 pokazuje kartę %2 %3 graczowi %4. - + %1 reveals %2%3. %1 pokazuje kartę %2 %3. - + %1 reversed turn order, now it's %2. %1 odwrócona kolejność tur, teraz %2 - + reversed odwrócone - + normal normalne - + Heads Orzeł - + Tails Reszka - + %1 flipped a coin. It landed as %2. %1 podrzucił monetą. Wylądowała na %2. - + %1 rolls a %2 with a %3-sided die. %1 rzuca %2 kostką %3-ścienną. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 rzuca %2 monetami. Wypadło %3 orłów i %4 reszek. - + %1 rolls a %2-sided dice %3 times: %4. %1 rzuca %2-ścienną kostką %3 razy: %4. - + %1's turn. Tura gracza %1 - + %1 sets annotation of %2 to %3. %1 ustala adnotację karty %2 na %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 ustawia licznik %2 na %3 (%4%5). - + %1 sets %2 to not untap normally. %1 blokuje karcie %2 normalne odtapowywanie. - + %1 sets %2 to untap normally. %1 umożliwia karcie %2 normalne odtapowywanie. - + %1 removes the PT of %2. %1 usuwa PT %2. - + %1 changes the PT of %2 from nothing to %4. %1 zmienia PT %2 z niczego na %4 - + %1 changes the PT of %2 from %3 to %4. %1 zmienia PT %2 z %3 na %4. - + %1 has locked their sideboard. %1 zablokował jego bibliotekę poboczną - + %1 has unlocked their sideboard. %1 odblokował jego bibliotekę poboczną - + %1 taps their permanents. %1 tapuje swoje permanenty. - + %1 untaps their permanents. %1 odtapowuje swoje permanenty. - + %1 taps %2. %1 tapuje kartę %2. - + %1 untaps %2. %1 odtapowuje kartę %2. - + %1 shuffles %2. %1 tasuje %2. - + %1 shuffles the bottom %3 cards of %2. %1 tasuje %3 dolne karty z %2. - + %1 shuffles the top %3 cards of %2. %1 tasuje %3 górne karty z %2. - + %1 shuffles cards %3 - %4 of %2. %1 tasuje %3 - %4 z %2 kart. - + %1 unattaches %2. %1 odczepia kartę %2. - + %1 undoes their last draw. %1 cofa ostatnią dobraną kartę - + %1 undoes their last draw (%2). %1 cofa ostatnią dobraną kartę (%2) @@ -6394,115 +8046,125 @@ Cockatrice ponownie załaduje teraz bazę danych. MessagesSettingsPage - + Word1 Word2 Word3 Wyraz1 Wyraz2 Wyraz3 - + Add New Message Dodaj Nową Wiadomość - + Edit Message Edytuj Wiadomość - + Remove Message Usuń Wiadomość - + Add message Dodaj wiadomość - - + + Message: Wiadomość: - + Edit message Edytuj wiadomość - + Chat settings Ustawienia czatu - + Custom alert words Niestandardowy tekst alertu. - + Enable chat mentions Włącz wywołania na czacie - + Enable mention completer Włącz autouzupełnianie wywołania - + In-game message macros Makra wiadomości w trakcie gry - + How to use in-game message macros Jak korzystać z makr wiadomości w trakcie gry - + Ignore chat room messages sent by unregistered users Ignoruj wiadomości na czacie od niezarejestrowanych użytkowników - + Ignore private messages sent by unregistered users Ignoruj prywatne wiadomości od niezarejestrowanych użytkowników - + Ignore private messages sent by non-buddy users - - + + Invert text color Odwróć kolor tekstu - + Enable desktop notifications for private messages Włącz desktopowe notyfikacje dla prywatnych wiadomości - + Enable desktop notification for mentions Włącz desktopowe notyfikacje dla wywołań - + Enable room message history on join Włącz historię wiadomości w pokoju po dołączeniu - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Kolor w kodzie heksadecymalnym) - + Separate words with a space, alphanumeric characters only Rozdziel słowa spacją, tylko alfanumeryczne znaki @@ -6510,42 +8172,42 @@ Cockatrice ponownie załaduje teraz bazę danych. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6624,148 +8286,134 @@ Cockatrice ponownie załaduje teraz bazę danych. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6888,30 +8536,21 @@ Cockatrice ponownie załaduje teraz bazę danych. Krok końca tury - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7030,16 +8669,272 @@ Cockatrice ponownie załaduje teraz bazę danych. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7060,12 +8955,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7073,25 +8968,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing Przypnij wersję - + Unpin Printing Odepnij wersję - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards Pokaż powiązane karty + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7122,28 +9057,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical Alfabetycznie - + Preference - + Release Date Data wydania - - + + Descending Malejąco - + Ascending Rosnąco @@ -7159,95 +9094,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Usługi - + Hide %1 Ukryj %1 - + Hide Others Ukryj pozostałe - + Show All Pokaż wszystkie - + Preferences... Preferencje… - + Quit %1 Zakończ %1 - + About %1 O programie %1 @@ -7255,17 +9198,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Baza kart Cockatrice (*.xml) - + All files (*.*) Wszystkie pliki (*.*) - + Cockatrice replays (*.cor) Powtórki Cockatrice (*.cor) @@ -7285,17 +9228,22 @@ This setting means you'll only see the default printing for each card, instead o Tokeny - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7306,7 +9254,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7317,100 +9265,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK - + Save Zapisz - + Save All Zapisz Wszystko - + Open Otwórz - + &Yes Tak - + Yes to &All Tak - wszystko - + &No Nie - + N&o to All Nie - wszystko - + Abort Przerwij - + Retry Powtórz - + Ignore Ignoruj - + Close Zamknij - + Cancel Anuluj - + Discard Odrzuć - + Help Pomoc - + Apply Aplikuj - + Reset Reset - + Restore Defaults Przywróć domyślne @@ -7418,121 +9376,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Nazwa - + ID ID - + Upload time Data dodania + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Nazwa - + Players Gracze - + Keep Zachowaj - + Time started Czas rozpoczęcia - + Duration (sec) Czas trwania (sek) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7574,37 +9600,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Pokoje - + Joi&n &Dołącz - + Room Pokój - + Description Opis - + Permissions Zezwolenia - + Players Gracze - + Games Gry @@ -7612,7 +9638,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7620,32 +9646,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table Wybierz akcję z tabeli - + Hit the key/combination of keys you want to set for this action Naciśnij klawisz lub kombinację klawiszy którą chcesz ustawić na tą akcję - + Invalid key Nieprawidłowy klawisz - + Shortcut already in use by: Skrót aktualnie użyty przez: - + Clear Wyczyść - + Restore default Przywróć domyślne @@ -7678,56 +9704,92 @@ Fine-tune individual colours in the grid afterwards. Data wydania + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts Przywróć domyślne skróty - + Do you really want to restore all default shortcuts? Czy na pewno chcesz przywrócić wszystkie domyślne skróty? - + Clear all default shortcuts Wyczyść wszystkie domyślne skróty - + Do you really want to clear all shortcuts? Czy na pewno chcesz wyczyścić wszystkie skróty? - + Section: Sekcja: - + Action: Akcja: - + Shortcut: Skrót: - + + Shortcut editor + + + + How to set custom shortcuts Jak ustawić niestandardowe skróty klawiszowe - + Clear all shortcuts Wyczyść wszystkie skróty - + Search by shortcut name Szukaj po nazwie skrótu @@ -7783,12 +9845,12 @@ Sprawdź ustawienia skrótów! SideboardMenu - + &Sideboard - + &View sideboard @@ -7796,27 +9858,27 @@ Sprawdź ustawienia skrótów! SoundSettingsPage - + Enable &sounds Włącz &efekty dźwiękowe - + Current sounds theme: Aktualny zestaw dźwięków: - + Test system sound engine Testuj silnik dźwiękowy systemu - + Sound settings Ustawienia dźwięku - + Master volume Główne ustawienia głośności @@ -7824,48 +9886,48 @@ Sprawdź ustawienia skrótów! SpoilerBackgroundUpdater - + Spoilers season has ended Sezon spoilerów zakończony - + Deleting spoiler.xml. Please run Oracle Usuwanie spoiler.xml. Uruchom Oracle - - + + Spoilers download failed Pobieranie spoilerów nieudane - + No internet connection Brak połączenia z internetem - + Error Błąd - + Spoilers already up to date Spoilery są już aktualne - + No new spoilers added Nie dodano nowych spoilerów - + Spoilers have been updated! Spoilery zostały zaktualizowane. - + Last change: Ostatnia zmiana: @@ -7873,157 +9935,170 @@ Sprawdź ustawienia skrótów! StableReleaseChannel - + Default - + No reply received from the release update server. Brak odpowiedzi z serwera uaktualnień wersji. - + Invalid reply received from the release update server. Nieprawidłowa odpowiedź serwera uaktualnień wersji. - + No reply received from the tag update server. Brak odpowiedzi z serwera uaktualnień tagów. - + Invalid reply received from the tag update server. Nieprawidłowa odpowiedź serwera uaktualnień tagów. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8031,17 +10106,33 @@ Sprawdź ustawienia skrótów! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8162,118 +10253,118 @@ Sprawdź ustawienia skrótów! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8286,42 +10377,42 @@ Sprawdź ustawienia skrótów! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8334,52 +10425,52 @@ Sprawdź ustawienia skrótów! TabDeckEditor - + Card Info Informacje o karcie - + Deck Talia - + Filters Filtry - + &View &Widok - + Card Database - + Printing Wersja - + Visible Widoczne - + Floating Pływające - + Reset layout Resetuj układ - + Deck: %1 Talia: %1 @@ -8387,49 +10478,49 @@ Sprawdź ustawienia skrótów! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8437,22 +10528,22 @@ Sprawdź ustawienia skrótów! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8460,139 +10551,237 @@ Sprawdź ustawienia skrótów! TabDeckStorage - + Local file system Pliki lokalne - + Server deck storage Przechowalnia talii na serwerze - - + + Open in deck editor Otwórz w edytorze talii - + Rename deck or folder - + Upload deck Wyślij talię - + Download deck Pobierz talię - - - - + + + + New folder Nowy folder - - + + Delete Usuń - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder Otwórz folder talii - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error Błąd - + Rename failed - - + + + Invalid deck file Nieprawidłowy plik talii - + Enter deck name Podaj nazwę talii - + This decklist does not have a name. Please enter a name: Talia nie posiada nazwy. Wprowadź nazwę: - + Unnamed deck Nienazwana talia - + Failed to upload deck to server Nie udało się wysłać talii na serwer - + Delete local file Usuń plik lokalny - + Are you sure you want to delete the selected files? Czy na pewno chcesz usunąć wybrane pliki? - + Delete remote decks Usuń zdalną talię - + Are you sure you want to delete the selected decks? Czy na pewno chcesz usunąć wybrane talie? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Nazwa nowego folderu: - + Deck Storage @@ -8600,20 +10789,271 @@ Wprowadź nazwę: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8626,27 +11066,27 @@ Wprowadź nazwę: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8659,191 +11099,207 @@ Wprowadź nazwę: TabGame - - - + + + Replay Powtórka - - + + Game Gra - - + + Player List Lista Graczy - - + + + Invite + + + + + Card Info Informacje o karcie - - + + Messages Wiadomości - - + + Replay Timeline Oś czasu powtórki - + &Phases &Fazy - + &Game &Gra - + Next &phase Następna &faza - + Next phase with &action Następna faza z akcją - + Next &turn Następna &tura - + Reverse turn order Odwróć kolejność tur - + &Remove all local arrows Usuń wszystkie &wskaźniki - + Rotate View Cl&ockwise Obróć widok &zgodnie z ruchem wskazówek zegara - + Rotate View Co&unterclockwise Obróć widok &przeciwnie do ruchu wskazówek zegara - + Game &information &Informacje o grze - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede Powróć do gry - - - + + + &Concede &Poddaj grę - + &Leave game &Opuść grę - + C&lose replay &Zamknij powtórkę - + &Focus Chat Skup czat - + &Say: &Powiedz: - + Selected cards - + &View &Widok - + Visible Widoczne - + Floating Pływające - + Reset layout Resetuj układ - + Concede Poddaj grę - + Are you sure you want to concede this game? Czy na pewno chcesz poddać tę grę? - + Unconcede Powróć do gry - + You have already conceded. Do you want to return to this game? Poddałeś już się. Czy chcesz powrócić do tej gry? - + Leave game Opuść grę - + Are you sure you want to leave this game? Czy na pewno chcesz zakończyć tę rozgrywkę? - + A player has joined game #%1 Gracz dołączył do gry #%1 - + %1 has joined the game %1 dołączył(a) do gry. - + You have been kicked out of the game. Wyrzucono cię z gry. @@ -8859,158 +11315,158 @@ Wprowadź nazwę: TabLog - + Logs Logi - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs Logi Pokoju - + Game Logs Logi Gry - + Chat Logs Logi Czatu - - + + Error Błąd - + You must select at least one filter. Musisz wybrać przynajmniej jeden filtr. - + You have to select a valid number of days to locate. Musisz wybrać poprawną liczbę dni żeby zlokalizować. - + Username: Nazwa użytkownika: - + IP Address: Adres IP: - + Game Name: Nazwa rozgrywki: - + GameID: ID Gry: - + Message: Wiadomość: - + Main Room Pokój Główny - + Game Room Pokój - + Private Chat Rozmowa Prywatna - + Past X Days: Ostatnie X Dni: - + Today Dziś - + Last Hour Ostatnia godzina - + Maximum Results: Maksymalny Wynik: - + At least one filter is required. The more information you put in, the more specific your results will be. Wymagany jest co najmniej jeden filtr. Im więcej podanych informacji, tym bardziej sprecyzowany wynik. - + Get User Logs Pobierz Logi Użytkownika - + Clear Filters Wyczyść Filtry - + Filters Filtry - + Log Locations Loguj Połóżenia - + Date Range Okres - + Maximum Results Maksymalny Wynik - - + + Message History Historia wiadomości - + Failed to collect message history information. Nie udało się zebrać informacji o historii wiadomości. - + There are no messages for the selected filters. Brak wiadomości dla wybranych filtrów. @@ -9018,218 +11474,558 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. TabMessage - + Private &chat Prywatny &chat - + &Leave &Opuść - + %1 - Private chat %1 - Prywatny chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Ten użytkownik Cię ignoruje, nie może zobaczyć twoich wiadomości w głównym czacie oraz nie możesz dołączyć do jego gier. - + Private message from Prywatna wiadomość od - + %1 has left the server. Gracz %1 opuścił serwer. - + %1 has joined the server. Gracz %1 pojawił się na serwerze. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Lokalny system plików - + Server replay storage Powtórki na serwerze - - + + Watch replay Obejrzyj powtórkę - + Rename Zmień nazwę - - + + New folder Nowy folder - - + + Delete Usuń - + Open replays folder Otwórz folder powtórek - + Download replay Pobierz powtórkę - + Toggle expiration lock Wł./Wył. ochronę przed automatycznym usunięciem - + Get replay share code - - + + Look up replay by share code - + Rename local folder Zmień nazwę lokalnego folderu - + Rename local file Zmień nazwę lokalnego pliku - + New name: Nowa nazwa: - + Error Błąd - + Rename failed Zmiana nazwy nie powiodła się - + Name of new folder: Nazwa nowego folderu: - + Delete local file Usuń plik lokalny - + Are you sure you want to delete the selected files? Czy na pewno chcesz usunąć wybrane pliki? - + Are you sure you want to delete the selected replays? Czy na pewno chcesz usunąć wybrane powtórki? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Usuń przechowywaną powtórkę @@ -9239,65 +12035,478 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Powiedz: - + Chat Czat - + &Room P&okój - + &Leave room Op&uść pokój - + &Clear chat Wyczyść &czat - + Chat Settings... Ustawienia czatu… - + mentioned you. wspomniał o tobie. - + Click to view Kliknij aby podglądnąć - + You are flooding the chat. Please wait a couple of seconds. Spamujesz. Poczekaj chwilę. @@ -9305,35 +12514,35 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. TabServer - + Server Serwer - - - - + + + + Error Błąd - + Failed to join the server room: it doesn't exist on the server. Błąd podczas próby dołączenia do pokoju: nie istnieje na serwerze. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Serwer myśli że jesteś w pokoju ale twój klient nie może tego wyświetlić. Spróbuj zrestartować twojego klienta. - + You do not have the required permission to join this server room. Nie masz uprawnień do dołączenia do tego pokoju. - + Failed to join the server room due to an unknown error: %1. Błąd podczas próby dołączenia do pokoju z powodu nieznanego błędu: %1 @@ -9341,97 +12550,173 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Czy na pewno? - + There are still open games. Are you sure you want to quit? Ciągle masz otwarte gry. Czy na pewno chcesz wyjść? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view Kliknij aby podglądnąć - + Your buddy %1 has signed on! Twój znajomy %1 zalogował się! - + Unknown Event Nieznane zdarzenie - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9442,39 +12727,39 @@ Ta wiadomośc może oznaczać że jest dostępna nowa wersja Cockatrice, lub że Aby aktualizować klienta, otwórz okno Pomoc -> Sprawdź Aktualizacje - + Idle Timeout Przekroczono czas bezczynności - + You are about to be logged out due to inactivity. Zostaniesz wylogowany ze względu na nieaktywność. - + Promotion Awans - + You have been promoted. Please log out and back in for changes to take effect. Zostałeś awansowany. Proszę wyloguj się i zaloguj ponownie aby zmiany zostały wprowadzone. - + Warned Ostrzeżony - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Otrzymałeś ostrzeżenie z powodu %1. Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Jeżeli masz pytania, wyślij prywatną wiadomość do moderatora. - + You have received the following message from the server. (custom messages like these could be untranslated) Otrzymałeś następującą wiadomość od serwera. @@ -9494,6 +12779,34 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9508,6 +12821,65 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Nie można przeanalizować talii. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9577,57 +12949,52 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9635,216 +13002,266 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. UserContextMenu - + User &details &Dane użytkownika - + Private &chat Prywatny &chat - + Show this user's &games Wyświetl &gry użytkownika - + + View this user's &public decks + + + + Add to &buddy list &Dodaj do listy znajomych - + Remove from &buddy list &Usuń z listy znajomych - + Add to &ignore list Dodaj do listy &ignorowanych - + Remove from &ignore list Usuń z listy &ignorowanych - + Kick from &game Wyrzuć z &gry - + + Report user + + + + Warn user Daj ostrzeżenie użytkownikowi - + View user's war&n history Zobacz historie ostrzeżeń użytkownika - + Ban from &server Zbanuj z &serwera - + View user's &ban history Zobacz historie blokad użytkownika - + &Promote user to moderator &Daj użytkownikowi uprawnienia moderatora - + Dem&ote user from moderator &Zabierz użytkownikowi uprawnienia moderatora - + Promote user to &judge &Daj użytkownikowi uprawnienia sędziego - + Demote user from judge &Zabierz użytkownikowi uprawnienia sędziego - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes Sprawdź notatki administratorskie - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games Gry gracza %1 - - - + + + Ban History Historia blokad - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason Blokada Czas;Moderator;Czas Blokady;Powód Blokady;Widoczny Powód - + User has never been banned. Użytkownik nigdy nie został zablokowany - + Failed to collect ban information. Błąd podczas zbierania informacji o blokadach. - - - + + + Warning History Historia ostrzeżeń - + Warning Time;Moderator;User Name;Reason Ostrzeżenie Czas;Moderator;Nazwa Użytkownika;Powód - + User has never been warned. Użytkownik nigdy nie dostał ostrzeżenia. - + Failed to collect warning information. Błąd podczas zbierania informacji o ostrzeżeniach. - + Failed to get admin notes. Błąd podczas zbierania notatek administratorskich. - - + + Success Operacja zakończona pomyślnie - + Successfully promoted user. Pomyślny awans gracza. - + Successfully demoted user. Pomyślna degradacja gracza. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Wystąpił błąd - + Failed to promote user. Błąd podczas awansu gracza. - + Failed to demote user. Błąd podczas degradacji gracza. - + Copy hash to clipboard Zapisz hash do schowka - + Remove this user's messages Usuń wiadomości tego użytkownika @@ -9882,61 +13299,61 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Administrator - + Moderator Moderator - + Registered user Zarejestrowany użytkownik - - + + Unregistered user Niezarejestrowany użytkownik - + Judge Sędzia - + Unknown Nieznany - + The entered password does not match your account. Wpisane hasło nie pasuje do twojego konta. - - - - + + + + Information Informacje - + User information updated. Informacje o użytkowniku zaktualizowane. - - - - - - + + + + + - - - + + + + Error Błąd @@ -9960,283 +13377,292 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n Rok%n Lat%n Lat%n Lat - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Dzień %20%10%n Dni %20%10%n Dni %20%10%n Dni %20 - + Enter Password Wpisz hasło - + Password verification is required in order to change your email address Weryfikacja hasłem jest wymagana aby zmienić twój adres email. - - - + + + An error occurred while trying to update your user information. Wystąpił błąd podczas aktualizacji informacji o użytkowniku. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Ten serwer nie pozwala na aktualizację twoich informacji o użytkowniku. - + Password changed. Hasło zmienione. - + This server does not permit you to change your password. Ten serwer nie pozwala na zmianę twojego hasła. - + The new password is too short. Nowe hasło jest za krótkie. - + The old password is incorrect. Stare hasło jest niepoprawne. - + Avatar updated. Awatar zaktualizowany - + This server does not permit you to update your avatar. Ten serwer nie pozwala na aktualizację twojego awatara. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Ten serwer nie pozwala na aktualizację twoich informacji o użytkowniku. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10244,223 +13670,344 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. UserInterfaceSettingsPage - + General interface settings Ogólne ustawienia interfejsu - + &Double-click cards to play them (instead of single-click) Zagrywaj karty po&dwójnym kliknięciem (zamiast pojedynczym) - + &Clicking plays all selected cards (instead of just the clicked card) Kliknięcie zagrywa wszystkie wybrane karty (zamiast tylko klikniętej karty) - + &Play all nonlands onto the stack (not the battlefield) by default Karty nie będące lądami zagrywaj domyślnie na stos (zamiast na &pole bitwy) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Dodaj tekst karty na tokenach - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Użycie odczepianych menu, pozwalające na zatrzymanie menu kontekstowego na ekranie - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings Ustawienia powiadomień - + Enable notifications in taskbar Włącz &powiadomienia na pasku zadań - + Notify in the taskbar for game events while you are spectating Powiadomienia na pasku zadań dla gier, które &obserwujesz - + Notify in the taskbar when users in your buddy list connect Powiadamiaj na pasku zadań kiedy użytkownik z twojej listy znajomych zaloguje się. - + Animation settings Ustawienia animacji - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation Animacja &tapowania/odtapowania - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default Domyślnie otwórz talię w nowej karcie - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings Ustawienia powtórek - + Buffer time for backwards skip via shortcut: Buforuj czas do przeskoku w tył wywołane przez skrót: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10497,22 +14044,22 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10520,72 +14067,72 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10687,7 +14234,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10695,19 +14242,19 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10753,28 +14300,28 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10782,43 +14329,43 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10834,12 +14381,12 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10847,17 +14394,17 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10865,7 +14412,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10873,52 +14420,57 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10926,7 +14478,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10934,22 +14486,22 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10957,17 +14509,22 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10975,177 +14532,202 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. WarningDialog - + Which warning would you like to send? Jakie ostrzeżenie chciałbyś wysłać? - + Redact all messages from this user in all rooms Redaguj wiadomości od tego użytkownika we wszystkich pokojach - + &OK &OK - + &Cancel &Anuluj - + Warn user for misconduct Ostrzeż gracza za złe zachowanie - - + + Error Błąd - + User name to send a warning to can not be blank, please specify a user to warn. Nazwa gracza do wysłania ostrzeżenia nie może być pusta, prosze podać gracza. - + Warning to use can not be blank, please select a valid warning to send. Ostrzeżenie nie może być puste, zaznacz poprawne ostrzeżenie do wysłania. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Przesuń zaznaczony set do góry - + Move selected set up Przesuć wybrany dodatek w górę - + Move selected set down Przesuń wybrany dodatek w dół - + Move selected set to the bottom Przesuń zaznaczony set na dół - - Search by set name, code, or type - Szukaj po nazwie dodatku, kodzie lub typie - - - + Default order Domyślna kolejność - + Restore original art priority order Przywróć oryginalny priorytet obrazów kart - + Enable all sets Włącz wszystkie dodatki - + Disable all sets Wyłącz wszystkie dodatki - + Enable selected set(s) Włącz zaznaczony/e set(y) - + Disable selected set(s) Wyłącz zaznaczony/e set(y) - + Deck Editor Edycja Talii - + Use CTRL+A to select all sets in the view. Użyj CTRL+A aby wybrać wszystkie dodatki w widoku. - + Only cards in enabled sets will appear in the card list of the deck editor. Tylko karty z odblokowanych dodatków pojawią się w edytorze talii kart - + Image priority is decided in the following order: Priorytet obrazów jest ustalany w następujący sposób: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki najpierw folder NIESTANDARDOWYCH (%1), potem Włączone Dodatki w tym oknie dialogowym (Z góry na dół) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Obraz karty - + + Search by set name, code, type, or release date + + + + How to use custom card art Jak ustawić niestandardowe obrazy do kart - + Hints Wskazówki - + Note Notatka - + Sorting by column allows you to find a set while not changing set priority. Sortowane kolumnami pozwala ci znaleźć dodatek nie zmieniając ich priorytetów. - + To enable ordering again, click the column header until this message disappears. Aby wyłączyć sortowanie klikaj ponownie na ten sam nagłówek kolumny aż zniknie ten komunikat. - + Use the current sorting as the set priority instead Użyj tymczasowego sortowania jako priorytet dodatków - + Sorts the set priority using the same column Sortuje priorytet dodatków korzystając z tej samej kolumny - + Manage sets Zarządzaj dodatkami @@ -11153,93 +14735,85 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped Niepogrupowane - + Group by Type Grupuj po typie - + Group by Mana Value Grupuj po wartości many - + Group by Color Grupuj po kolorze - + Unsorted Nieposortowane - + Sort by Name Sortuj według nazwy - + Sort by Type Sortuj według typu - + Sort by Mana Cost Sortuj według wartości many - + Sort by Colors Sortuj według koloru - + Sort by P/T Sortuj według P/T - + Sort by Set Sortuj według dodatku - + shuffle when closing przetasuj przy zamykaniu - + pile view widok sterty - - i18n - - - English - Polski (Polish) - - main - + Connect on startup Zaloguj po uruchowmieniu - + Debug to file Debuguj do pliku @@ -12023,6 +15597,16 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Graveyard Grób + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_pl.ts b/oracle/translations/oracle_pl.ts index cad6309a9..567044f74 100644 --- a/oracle/translations/oracle_pl.ts +++ b/oracle/translations/oracle_pl.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Wprowadzenie - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Ten kreator zaimportuje listę dodatków, kart oraz tokenów które zostaną użyte przez Cockatrice. - + Interface language: Język interfejsu: - + Version: Wersja: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Wybór źródła - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Proszę podać źródło listy edycji i kart. Można podać adres URL, z którego zostanie pobrana, lub istniejący plik na komputerze. - + Download URL: Pobierz URL - + Local file: Plik lokalny: - + Restore default URL Przywróć domyślny URL - + Choose file... Wybierz plik… - + Load sets file Wczytaj listę dodatków - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Błąd - + The provided URL is not valid. Podano nieprawidłowy URL. - - Downloading (0MB) - Pobieranie (0MB) - - - + Please choose a file. Proszę wybrać plik. - + Cannot open file '%1'. Nie można otworzyć pliku '%1'. - - Downloading (%1MB) - Pobieranie (%1MB) - - - + Network error: %1. Błąd sieci: %1. - + Parsing file Analizowanie pliku - + Xz extraction failed. Rozpakowanie XZ nie udało się. - + Sorry, this version of Oracle does not support xz compressed files. Przepraszamy, ta wersja Oracle nie obsługuje plików spakowanych w archiwum xz. - + Failed to open Zip archive: %1. Otwieranie archiwum Zip zakończone niepowodzeniem: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Rozpakowanie pliku Zip nieudane: archiwum nie zawiera dokładnie jednego pliku. - + Zip extraction failed: %1. Rozpakowanie Zip nieudane: %1. - + Sorry, this version of Oracle does not support zipped files. Przepraszamy, ta wersja Oracle nie obsługuje plików spakowanych w archiwum Zip. - + Failed to interpret downloaded data. Interpretacja pobranych danych nie udała się. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Czy chcesz zamiast tego pobrać plik nieskompresowany? - + + The file was retrieved successfully, but it does not contain any sets data. Plik został pobrany z powodzeniem, ale nie zawiera informacji o dodatkach. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Zapisz bazę danych spoilerów. - + XML; spoiler database (*.xml) XML; baza danych spoilerów (*.xml) - + spoiler - + Spoilers import Import spoilerów - + Please specify a compatible source for spoiler data. Proszę wybrać kompatybilne źródło danych ze spoilerami. - + Download URL: URL pobierania: - + Local file: - + Restore default URL Przywróć domyślny URL - + Choose file... - + The spoiler database will be saved at the following location: Baza danych spoilerów zostanie zapisana w tym miejscu: - + Save to a custom path (not recommended) Zapisz do niestandardowej ścieżki (niezalecane) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database Zapisz bazę danych tokenów. - + XML; token database (*.xml) XML; baza danych tokenów (*.xml) - + tokens - + Tokens import Import tokenów - + Please specify a compatible source for token data. Proszę wybrać kompatybilne źródło danych z tokenami. - + Download URL: Pobierz odnośnik: - + Local file: - + Restore default URL Przywróć domyślny URL - + Choose file... - + The token database will be saved at the following location: Baza danych tokenów zostanie zapisana w tym miejscu: - + Save to a custom path (not recommended) Zapisz do niestandardowej ścieżki (niezalecane) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Dodatek-atrapa, zawierający tokeny. @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle – kreator importu @@ -292,22 +306,22 @@ OutroPage - + Finished Zakończone - + The wizard has finished. Kreator zakończył swoją pracę. - + You can now start using Cockatrice with the newly updated cards. Możesz teraz korzystać z Cockatrice z nowo zaktualizowanymi kartami. - + If the card databases don't reload automatically, restart the Cockatrice client. Jeżeli bazy kart nie przeładują się automatycznie, zrestartuj klienta Cockatrice. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Błąd - + + No set has been imported. Nie zaimportowano żadnego dodatku. - + Sets imported Zaimportowane dodatki - - A cockatrice database file of %1 MB has been downloaded. - Baza danych Cockatrice mająca %1 MB została pobrana. + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: Następujące dodatki zostały znalezione: - + Press "Save" to store the imported cards in the Cockatrice database. Kliknij "Zapisz" aby zapisać zaimportowane karty w bazie danych Cockatrice. - + The card database will be saved at the following location: Baza danych kart zostanie zapisana w tym miejscu: - + Save to a custom path (not recommended) Zapisz do niestandardowej ścieżki (niezalecane) - + &Save Zapisz - + + Import finished: %1 cards. Import zakończony: %1 kart. - + + %1: %2 cards imported %1: zaimportowano %2 kart - + Save card database Zapisz bazę kart - + XML; card database (*.xml) XML, baza kart (*.xml) - + The file could not be saved to %1 Plik nie mógł zostać zapisany do %1 @@ -414,8 +431,9 @@ - Downloading (0MB) - Pobieranie (0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,7 +605,7 @@ i18n - + English Polski (Polish) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Tylko działaj w trybie spoilerów - + Run in no-confirm background mode From aee866b242e624380eb25e26b5a2d34527b14d1f Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:47:26 +0200 Subject: [PATCH 066/247] Updates for project Cockatrice and language sr (#7566) * Translate oracle_en@source.ts in sr [Manual Sync] 23% of minimum 5% translated source file: 'oracle_en@source.ts' on 'sr'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in sr [Manual Sync] 7% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'sr'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_sr.ts | 9540 ++++++++++++++++------ oracle/translations/oracle_sr.ts | 200 +- 2 files changed, 7092 insertions(+), 2648 deletions(-) diff --git a/cockatrice/translations/cockatrice_sr.ts b/cockatrice/translations/cockatrice_sr.ts index c04d16f16..9bb962ced 100644 --- a/cockatrice/translations/cockatrice_sr.ts +++ b/cockatrice/translations/cockatrice_sr.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Podesi brojač... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Podesi brojač - + New value for counter '%1': Nova vrednost za brojač '%1': @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab Otvori u novoj kartici - + Are you sure? Da li ste sigurni? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +176,313 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings Podešavanja teme - + Current theme: Sadašnja tema: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering - + Display card names on cards having a picture Pokazuj imena karti na kartama koje imaju sliku - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + + System + + + + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + + Active theme palette: + + + + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Raspored ruke - + Display hand horizontally (wastes space) Prikazuj ruku horizontalno (troši prostor) - + Enable left justification - + Table grid layout - + Invert vertical coordinate - + Minimum player count for multi-column layout: - + Maximum font size for information displayed on cards: @@ -300,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name zabrani ime igrača - + ban &IP address zabrani IP adresa - + ban client I&D zabranite ID klijenta - + Ban type Tip zabrane - + &permanent ban stalna zabrana - + &temporary ban privremena zabrana - + &Days: Dani: - + &Hours: Sati: - + &Minutes: Minuti: - + Duration of the ban Dužina trajanja zabrane - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Molimo Vas ukucajte razlog zabrane. Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene osobe. - + Please enter the reason for the ban that will be visible to the banned person. Molimo Vas ukucajte razlog zabrane koji će biti vidljiv zabranjenoj osobi. - + Redact all messages from this user in all rooms - + &OK U redu - + &Cancel Poništi - + Ban user from server Zabranite ulaz korisniku u server - - - - + + + + Error Greška - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -444,59 +634,280 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + Banner preview + + + + + + + + Zoom %1× + + + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Ime - + Sets Setovi - + Mana cost Cena mane - + Card type Tip karte - + P/T - + Color(s) Boja (boje) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Add to Zone + + + + + Create &new zone... + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -602,22 +1013,22 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -625,22 +1036,22 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -648,27 +1059,27 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -676,132 +1087,150 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget - + Card Size @@ -809,138 +1238,156 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -957,16 +1404,523 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1928,35 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1972,17 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1061,97 +1990,158 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +2149,17 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1177,114 +2167,119 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1292,7 +2287,7 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1300,199 +2295,185 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckEditorSettingsPage - - + + Update Spoilers - - + Success - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL - - + + URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - - - - + Reset Download URLs - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1500,27 +2481,27 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckListModel - + Count - + Set - + Number Broj - + Provider ID - + Card Karta @@ -1528,12 +2509,12 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1541,17 +2522,22 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -1559,7 +2545,7 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -1567,62 +2553,62 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -1630,94 +2616,109 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -1725,57 +2726,102 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,94 +2843,122 @@ Ovo je samo sačuvano za moderatore i ne može biti viđeno od strane zabranjene DeckViewContainer - + Load deck... Učitaj špil... - + Load remote deck... Učitaj daljinski špil... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Greška - + The selected file could not be loaded. Izabrani fajl nije mogao biti učitan. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -1897,143 +2971,143 @@ This will kick all non-ready players from the game. - + Known Hosts - + Delete the currently selected saved server - + Refresh the server list with known public servers - + New Host Novi Domaćin - + Name: - + &Host: Domaćin: - + &Port: - + Player &name: Ime igrača: - + P&assword: Lozinka: - + &Save password Sačuvajte lozinku: - + A&uto connect Automatsko povezivanje - + Automatically connect to the most recent login when Cockatrice opens Automatski se poveži na najskoriju prijavu kada se Cockatrice otvori - + If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage - + Reset Password - + Forgot password? - + &Connect - + Server Server - + Login Prijava - + Server Contact - + Connect to Server - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning Upozorenje o povezivanju - + The player name can't be empty. Ime igrača ne može biti prazno. @@ -2041,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Za&pamti podešavanja - + &Description: Opis: - + P&layers: Igrači: - + General - + Game type Tip igre - + &Password: Lozinka: - + Only &buddies can join Samo prijatelji mogu da se pridruže - + Only &registered users can join Samo &registrovani korisnici mogu da se pridruže - + Joining restrictions Ograničenja pridruživanja - + &Spectators can watch Posmatrači mogu da gledaju - + Spectators &need a password to watch Posmatračima &je potrebna lozinka da gledaju - + Spectators can &chat Posmatrači mogu &da ćaskaju - + Spectators can see &hands Posmatrači mogu da &vide ruke - + Create game as spectator - + Spectators Posmatrači - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear Očisti - + Create game Započni igru - + Game information Informacije o igri - + Error Greška - + Server error. Greška servera. @@ -2164,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: Ime: - + Token Žeton - + C&olor: Boja: - + white belo - + blue plavo - + black crno - + red crveno - + green zeleno - + multicolor višebojno - + colorless bezbojno - + &P/T: M/I: - + &Annotation: Anotacija: - + &Destroy token when it leaves the table Uništi token kada napusti sto - + Create face-down (Only hides name) - + Token data Podaci o tokenima - + Show &all tokens Prikaži sve tokene - + Show tokens from this &deck Prikaži tokene iz ovog špila - + Choose token from list Izaberi token iz liste - + Create token Napravi token @@ -2262,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2317,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Slika nije izabrana. @@ -2329,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. Da izbrišete vaš sadašnji avatar, potvrdite bez biranja nove slike. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Pretraži... - + Change avatar Promeni avatar - + Open Image Otvori sliku - + Image Files (*.png *.jpg *.bmp) Fajlovi Slika (*.png *.jpg *.bmp) - + Invalid image chosen. Neispravna slika izabrana. @@ -2375,38 +3454,38 @@ Da izbrišete vaš sadašnji avatar, potvrdite bez biranja nove slike. DlgEditPassword - + Old password: Stara lozinka: - + New password: Nova lozinka: - + Confirm new password: Potvrdi novu lozinku: - + Change password Promeni lozinku - - + + Error Greška - + Your password is too short. - + The new passwords don't match. Nove lozinke se ne podudaraju @@ -2414,93 +3493,93 @@ Da izbrišete vaš sadašnji avatar, potvrdite bez biranja nove slike. DlgEditTokens - + &Name: Ime: - + C&olor: Boja: - + white belo - + blue plavo - + black crno - + red crveno - + green zeleno - + multicolor višebojno - + colorless bezbojno - + &P/T: M/I: - + &Annotation: Anotacija: - + Token data Podaci o tokenima - - + + Add token Dodaj token - + Remove token Uklonite token - + Edit custom tokens - + Please enter the name of the token: Molim Vas, ukucajte ime tokena: - + Error Greška - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -2524,12 +3603,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia Neodređeno - + Real name: Pravo ime: - + Edit user profile Promenite korisnički profil @@ -2567,113 +3646,115 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Hide 'buddies only' games - + Hide full games - - Hide games that have started + + Hide started games + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: Opis igre: - - &Creator name: - Ime kreatora: + + &Host name: + &Creator name: + - + General - + &Game types Tipovi igara - + at &least: barem: - + at &most: najviše: - + Maximum player count Maksimalni broj igrača - + Restrictions Ograničenja - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games Filtriraj igre @@ -2681,52 +3762,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: - + &Port: - + Player &name: - + Email: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. @@ -2734,37 +3815,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: - + &Port: - + Player &name: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. @@ -2772,94 +3853,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: - + &Port: - + Player &name: - + Token: - - + + New Password: - + Reset Password - + The player name can't be empty. - - - - + + + + Reset Password Error - + The token can't be empty. - + The new password can't be empty. - + Error - + Your password is too short. - + The passwords do not match. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -2943,180 +4057,581 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: Domaćin: - + &Port: - + Player &name: Ime igrača: - + P&assword: Lozinka: - + Password (again): Lozinka (ponovo): - + Email: I-mejl: - + Email (again): I-mejl (ponovo): - + Country: Država: - + Undefined Neodređeno - + Real name: Pravo ime: - + Register to server Registruj se na server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Upozorenje o registraciji - + Your password is too short. - + Your passwords do not match, please try again. Vaše lozinke se ne podudaraju, molim Vas, pokušajte ponovo - + Your email addresses do not match, please try again. Vaše i-mejl adrese se ne podudaraju, molimo Vas, pokušajte ponovo. - + The player name can't be empty. Ime igrača ne može biti prazno. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3167,34 +4682,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Nepoznata Greška učitavanja baze podataka karte - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3203,14 +4696,14 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3219,7 +4712,7 @@ Would you like to change your database location setting? Da li bi ste želeli da promenite vaše podešavanje lokacije baze podataka? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3228,99 +4721,235 @@ Would you like to change your database location setting? - - - + + + Error Greška - + The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings Podešavanja - + + Search settings... + + + + + OK + + + + General Opšto - + Appearance Izgled - + User Interface Korisnički interfejs - + + Storage + + + + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Ćaskanje - + Sound Zvuk - + Shortcuts Prečice + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3328,17 +4957,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next - + Previous - + Tip of the Day @@ -3346,164 +4975,174 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel - + Reinstall - + Cancel Download - + Open Download Page Otvori Stranicu Za Preuzimanje - + Check for Client Updates - - - - + + + + Error Greška - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 - + Checking for updates... Provera za ažuriranja... - + Finished checking for updates - + No Update Available - + Cockatrice is up to date! - + You are already running the latest version available in the chosen release channel. - + Current version - + Selected release channel - - + + Update Available - - + + A new version of Cockatrice is available! - - + + New version - - + + Released - - + + Changelog - + Do you want to update now? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Greška Ažuriranja - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... Instalacija... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3511,17 +5150,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log @@ -3593,67 +5232,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3759,7 +5398,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -3767,7 +5406,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -3775,45 +5414,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3821,140 +5503,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Greška - + Please join the appropriate room first. - + Wrong password. Pogrešna lozinka. - + Spectators are not allowed in this game. Posmatrači nisu dozvoljeni u ovoj igri. - + The game is already full. Igra je već puna. - + The game does not exist any more. Ova igra više ne postoji. - + This game is only open to registered users. Ova igra je samo otvorena za registrovane korisnike. - + This game is only open to its creator's buddies. Ova igra je samo otvorena za prijatelje njenog kreatora. - + You are being ignored by the creator of this game. Vi ste ignorisani od strane kreatore ove igre. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game Pridruži se igri - - Password: - Lozinka: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. - + &Filter games Filtriraj igre - + C&lear filter Očisti filter - + C&reate Kreiraj - + &Join Pridruži se - + Join as judge - + J&oin as spectator Pridruži se kao posmatrač - + Join as judge spectator - + Games shown: %1 / %2 - + Games Igre @@ -3962,32 +5660,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -3995,105 +5693,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password lozinka - + buddies only samo prijatelji - + reg. users only samo reg. korisnici - + open decklists - - + + can chat može da ćaska - + see hands vidi ruke - + can see hands može da vidi ruke - + not allowed nije dozvoljeno - + Room Soba - + Age Starost - + Description Opis - - Creator - Kreator + + Host + - + Type Tip - + Restrictions Ograničenja - + Players Igrači - + Spectators Posmatrači @@ -4101,143 +5799,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + Srpski (Serbian) + + + + + + + + + Choose path Izaberi put - - Personal settings - Lična podešavanja + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + - + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + + Language settings + + + + Language: Jezik: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + + Version settings + + + + + Card database + + + + + Startup settings + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) - + Paths Putevi - + How to help with translations - + Decks directory: - + Filters directory: - + Replays directory: - + Pictures directory: - + Card database: Baza podataka karata: - + Custom database directory: - + Token database: Baza podataka tokena: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4245,47 +6075,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4293,355 +6123,649 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4649,831 +6773,474 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - - - - - There are too many concurrent connections from your address. - - - - - Banned by moderator - Zabranjen od strane moderatora - - - - Expected end time: %1 - - - - - This ban lasts indefinitely. - - - - - Scheduled server shutdown. - - - - - - Invalid username. - Nevažeće korisničko ime. - - - - You have been logged out due to logging in at another location. - - - - - Connection closed - Povezanost prekinuta - - - - The server has terminated your connection. -Reason: %1 - - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - - - - - Scheduled server shutdown - - - - - - Success - Uspeh - - - - Registration accepted. -Will now login. - - - - - Account activation accepted. -Will now login. - - - - - + + Player %1 - + Load replay - + About Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error - - Server timeout - - - - - Failed Login - - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - - - - - There is already an active session using this user name. -Please close that session first and re-login. - - - - - - You are banned until %1. - - - - - - You are banned indefinitely. - - - - - This server requires user registration. Do you want to register now? - - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - - - - - Unknown login error: %1 - - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - - Your username must respect these rules: - - - - - is %1 - %2 characters long - - - - - can %1 contain lowercase characters - - - - - - - - NOT - - - - - can %1 contain uppercase characters - - - - - can %1 contain numeric characters - - - - - can contain the following punctuation: %1 - - - - - first character can %1 be a punctuation mark - - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - - Registration denied - - - - - Registration is currently disabled on this server - - - - - There is already an existing account with the same user name. - - - - - It's mandatory to specify a valid email address when registering. - - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - - - - - Registration failed for a technical problem on the server. - - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - - - - - Account activation failed - - - - - Socket error: %1 - - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - - - - - Connecting to %1... - - - - - Registering to %1 as %2... - - - - - Disconnected - - - - - Connected, logging in at %1 - - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... - + &Disconnect - + Start &local game... - + &Watch replay... - + &Full screen - + &Register to server... - + &Restore password... - + &Settings... - + &Exit - + A&ctions - + &Cockatrice - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help - + &About Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - - - - - Activation request received, please check your email for an activation token. - - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5545,27 +7312,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5635,610 +7402,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play - + from their graveyard - + from exile - + from their hand - + the top card of %1's library - + the top card of their library - + from the top of %1's library - + from the top of their library - + the bottom card of %1's library - + the bottom card of their library - + from the bottom of %1's library - + from the bottom of their library - + from %1's library - + from their library - + from sideboard - + from the stack - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card - + %1 gives %2 control over %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. - + The game has started. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. - + You have been kicked out of the game. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6246,110 +8018,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message - - + + Message: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6357,42 +8144,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6405,47 +8192,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6468,6 +8255,138 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + + Save + + + + + Delete this scheme's custom palette and revert to the theme default (or the application palette) + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Cannot save: this theme has no writable directory + + + + + Write palette-%1.toml and reload the theme + + + + + + Save failed + + + + + + Could not write %1 to: +%2 + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + + Phase @@ -6534,222 +8453,460 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step - + Upkeep step - + Draw step - + First main phase - + Beginning of combat step - + Declare attackers step - + Declare blockers step - + Combat damage step - + End of combat step - + Second main phase - + End of turn step - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - + + + grave - - - + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -6770,12 +8927,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -6783,25 +8940,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -6832,28 +9029,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -6869,95 +9066,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -6965,17 +9170,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) @@ -6995,17 +9200,22 @@ This setting means you'll only see the default printing for each card, instead o - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7016,7 +9226,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7027,189 +9237,334 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name - + ID - + Upload time + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7217,37 +9572,37 @@ Are you sure you would like to disable this feature? RoomSelector - + Rooms - + Joi&n - + Room - + Description - + Permissions - + Players - + Games @@ -7255,7 +9610,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7263,32 +9618,32 @@ Are you sure you would like to disable this feature? SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7296,81 +9651,117 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled - + Set type - + Set code - + Long name - + Release date + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7424,12 +9815,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7437,27 +9828,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7465,48 +9856,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7514,45 +9905,204 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -7673,118 +10223,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7794,55 +10344,103 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor - + Card Info - + Deck - + Filters - + &View - + Card Database - + Printing - + Visible - + Floating - + Reset layout - + Deck: %1 @@ -7850,55 +10448,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7906,22 +10498,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7929,138 +10521,236 @@ Please check your shortcut settings! TabDeckStorage - + Local file system - + Server deck storage - - + + Open in deck editor - + Rename deck or folder - + Upload deck - + Download deck - - - - + + + + New folder - - + + Delete - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name - + This decklist does not have a name. Please enter a name: - + Unnamed deck - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: - + Deck Storage @@ -8068,20 +10758,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8094,27 +11035,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8127,191 +11068,207 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + + Invite + + + + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases - + &Game - + Next &phase - + Next phase with &action - + Next &turn - + Reverse turn order - + &Remove all local arrows - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede - + &Leave game - + C&lose replay - + &Focus Chat - + &Say: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede - + Are you sure you want to concede this game? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game - + Are you sure you want to leave this game? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8327,157 +11284,157 @@ Please enter a name: TabLog - + Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs - + Chat Logs - - + + Error - + You must select at least one filter. - + You have to select a valid number of days to locate. - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8485,218 +11442,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat - + &Leave - + %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. - + %1 has joined the server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -8706,50 +12003,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - + &Say: - + Chat - + &Room - + &Leave room - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. @@ -8757,35 +12482,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server - - - - + + + + Error - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8793,97 +12518,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? - + There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8891,38 +12692,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8931,16 +12732,44 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -8955,6 +12784,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -8973,42 +12861,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9016,214 +12904,327 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Remove Banner Card + + + UserContextMenu - + User &details - + Private &chat - + Show this user's &games - - - Add to &buddy list - - - - - Remove from &buddy list - - - - - Add to &ignore list - - - - - Remove from &ignore list - - - - - Kick from &game - - - - - Warn user - - - View user's war&n history + View this user's &public decks - Ban from &server + Add to &buddy list - View user's &ban history + Remove from &buddy list - &Promote user to moderator + Add to &ignore list - Dem&ote user from moderator + Remove from &ignore list - Promote user to &judge + Kick from &game - Demote user from judge + Report user + Warn user + + + + + View user's war&n history + + + + + Ban from &server + + + + + View user's &ban history + + + + + &Promote user to moderator + + + + + Dem&ote user from moderator + + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - - - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9231,48 +13232,48 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: - + Account Age: - + Edit - + Change password - + Change avatar - + Administrator - + Moderator - + Registered user - - + + Unregistered user @@ -9282,334 +13283,702 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Unknown - + The entered password does not match your account. - - - + + + + Information - + User information updated. - - - - - - - - - - + + + + + + + + + + + Error - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. - + Password changed. - + This server does not permit you to change your password. - + The new password is too short. - + The old password is incorrect. - + Avatar updated. - + This server does not permit you to update your avatar. - - An error occured while trying to updater your avatar. + + An error occured while trying to update your avatar. + + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + Invite + + + + + Invite to your game + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. UserInterfaceSettingsPage - + General interface settings - + &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + + Disabled + + + + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect - + Animation settings - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9638,22 +14007,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -9734,22 +14103,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9767,32 +14136,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9800,27 +14169,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9828,7 +14197,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9836,19 +14205,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9856,37 +14225,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9894,28 +14263,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -9923,43 +14292,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -9975,12 +14344,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -9988,17 +14357,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10006,7 +14375,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10014,52 +14383,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10067,7 +14441,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10075,22 +14449,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10098,17 +14472,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10116,177 +14495,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK U redu - + &Cancel - + Warn user for misconduct - - + + Error - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + + Search by set name, code, type, or release date + + + + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10294,93 +14698,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing - + pile view - - i18n - - - English - Srpski (Serbian) - - main - + Connect on startup - + Debug to file @@ -10394,7 +14790,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor @@ -10475,7 +14871,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10590,129 +14986,129 @@ Please refrain from engaging in this activity or further actions may be taken ag - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter @@ -10722,724 +15118,754 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap - + Upkeep - + Draw Izvlačenje - + First Main Phase - + Start Combat - + Attack Napad - + Block - + Damage - + End Combat - + Second Main Phase - + End Kraj - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile Izgnanstvo - - - - + + + + Graveyard Groblje + + + Report Queue + + + + + Moderation + + - - + + Hand Ruka - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library Špil - + Sideboard - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/oracle/translations/oracle_sr.ts b/oracle/translations/oracle_sr.ts index 9532030b3..7db2c733b 100644 --- a/oracle/translations/oracle_sr.ts +++ b/oracle/translations/oracle_sr.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction Uvod - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. - + Interface language: - + Version: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection Odabir izvora - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: URL za preuzimanje: - + Local file: Lokalni fajl: - + Restore default URL Povrati uobičajeni URL - + Choose file... Izaberite fajl... - + Load sets file - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error Greška - + The provided URL is not valid. Dati URL nije važeći. - - Downloading (0MB) - Preuzimanje (0MB) - - - + Please choose a file. Molimo Vas izaberite fajl. - + Cannot open file '%1'. Nemoguće otvori fajl '%1'. - - Downloading (%1MB) - Preuzimanje (%1MB) - - - + Network error: %1. Mrežna greška: %1. - + Parsing file Parsiranje fajla - + Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. Neuspeh u otvaranju Zip arhive: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. - + Zip extraction failed: %1. - + Sorry, this version of Oracle does not support zipped files. Izvinite, ova verzija Oracle-a ne podržava zip fajlove. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database - + XML; spoiler database (*.xml) - + spoiler - + Spoilers import - + Please specify a compatible source for spoiler data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + Tokens import - + Please specify a compatible source for token data. - + Download URL: URL za preuzimanje: - + Local file: - + Restore default URL - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer @@ -292,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Greška - + + No set has been imported. - + Sets imported - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. - + + %1: %2 cards imported - + Save card database - + XML; card database (*.xml) - + The file could not be saved to %1 @@ -414,7 +431,8 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) @@ -587,7 +605,7 @@ i18n - + English Srpski (Serbian) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode From 3f93e93f13398d070e3c9a837a2ef948efba5dc0 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:07:52 +0200 Subject: [PATCH 067/247] Translate cockatrice_en@source.ts in sv [Manual Sync] (#7571) 16% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'sv'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_sv.ts | 7534 ++++++++++++++++------ 1 file changed, 5559 insertions(+), 1975 deletions(-) diff --git a/cockatrice/translations/cockatrice_sv.ts b/cockatrice/translations/cockatrice_sv.ts index 2f9eb911b..727f43588 100644 --- a/cockatrice/translations/cockatrice_sv.ts +++ b/cockatrice/translations/cockatrice_sv.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Placera polett... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings Temainställningar - + Current theme: Aktuellt tema: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Kortrendering - + Display card names on cards having a picture Visa kortnamn på kort som har bilder - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Skala kort på musen över - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Handlayout - + Display hand horizontally (wastes space) Visa hand horisontellt (slösar plats) - + Enable left justification Aktivera vänsterjustering - + Table grid layout Bordets rutnätlayout - + Invert vertical coordinate Invertera vertikal koordinat - + Minimum player count for multi-column layout: Minst antal spelare för multi-kolumnlayout: - + Maximum font size for information displayed on cards: Maximal teckenstorlek för information som visas på kort: @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name bannlys användar&namn - + ban &IP address bannlys &IP address - + ban client I&D förbjuda klienten I & D - + Ban type Typ av bannlysning - + &permanent ban &permanent bannlysning - + &temporary ban &temporär bannlysning - + &Days: &Dagar: - + &Hours: &Timmar: - + &Minutes: &Minuter: - + Duration of the ban Bannlysningens längd - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Vänligen ange anledningen till bannlysningen. Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. - + Please enter the reason for the ban that will be visible to the banned person. Vänligen ange en anledning för bannlysningen som kommer att visas för den bannlysta personen. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Avbryt - + Ban user from server Bannlys användaren från servern - - - - + + + + Error Fel - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Du måste välja en namnbaserad, IP-baserad, clientId-baserad eller en kombination av de tre för att lägga ett förbud. - + You must have a value in the name ban when selecting the name ban checkbox. Du måste ha ett värde i namnförbudet när du markerar kryssrutan för namnförbud. - + You must have a value in the ip ban when selecting the ip ban checkbox. Du måste ha ett värde i ip-förbudet när du markerar kryssrutan ip-banan. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Du måste ha ett värde i clientidförbudet när du markerar kryssrutan för klientidförbud. @@ -499,22 +634,22 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. BetaReleaseChannel - + Beta - + No reply received from the release update server. Inget svar mottaget från uppdateringsservern. - + Invalid reply received from the release update server. Ogiltigt svar mottaget från uppdateringsserveren. - + No reply received from the file update server. Inget svar mottaget från filuppdateringsservern. @@ -522,7 +657,20 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardDatabaseModel - + Name Namn - + Sets Utgåvor - + Mana cost Manakostnad - + Card type Korttyp - + P/T P/T - + Color(s) Färg(er) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -744,22 +1036,22 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -795,129 +1087,129 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardSizeWidget - + Card Size @@ -1078,6 +1370,24 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Uppdatera Spoilers - + Success - + Download URLs have been reset. - + Add URL Lägg till webbadress - - + + URL: - - + + Edit URL - + Add New URL - + Remove URL - + Updating... Uppdaterar ... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Välj sökväg - + URL Download Priority - + Spoilers Spoilers - + Download Spoilers Automatically Ladda ner Spoilers automatiskt - + Spoiler Location: Spoiler Plats: - + Last Change Senaste ändring - + Spoilers download automatically on launch Spoilers laddas ner automatiskt vid lanseringen - + Press the button to manually update without relaunching Tryck på knappen för att manuellt uppdatera utan omstart - + Do not close settings until manual update is complete - + Download card pictures on the fly Ladda enkelt ned bilder på kort - + How to add a custom URL Hur man lägger till en valfri webbadress - + Reset Download URLs Återställ nedladdnings-webbadresser @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number Nummer - + Provider ID - + Card Kort @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Ladda lek... - + Load remote deck... Ladda remote lek... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Fel - + The selected file could not be loaded. Den valda filen kunde ej laddas. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. Hämtar ... - + Known Hosts Kända värdar - + Delete the currently selected saved server - + Refresh the server list with known public servers Uppdatera servern listan med kända offentliga servrar - + New Host Ny värd - + Name: Namn: - + &Host: &Värd: - + &Port: &Port: - + Player &name: Spelar&namn: - + P&assword: &Lösenord: - + &Save password &Spara lösenord - + A&uto connect Anslut automatiskt - + Automatically connect to the most recent login when Cockatrice opens Koppla automatiskt till den senaste inloggningen när Cockatrice öppnas - + If you have any trouble connecting or registering then contact the server staff for help! Om du har problem med att ansluta eller registrera kontaktar du serverns personal för hjälp! - - + + Webpage Webbsida - + Reset Password - + Forgot password? - + &Connect &Ansluta - + Server Server - + Login Logga in - + Server Contact Serverkontakt - + Connect to Server Anslut till servern - + Server URL Server webbadress - + Communication Port - + Unique Server Name Unikt servernamn - + Connection Warning Anslutningsvarning - + You need to name your new connection profile. Du måste ange din nya anslutningsprofil. - + Connect Warning Anslutningsvarning - + The player name can't be empty. Fältet för spelarnamn kan inte lämnas tomt. @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings Kom ihåg inställningar - + &Description: &Beskrivning: - + P&layers: &Spelare: - + General Allmän - + Game type Speltyp - + &Password: &Lösenord: - + Only &buddies can join Endast &vänner kan ansluta - + Only &registered users can join Endast &registerade användare kan ansluta - + Joining restrictions Anslutingsbegränsningar - + &Spectators can watch Åskådare tillåtna - + Spectators &need a password to watch Åskådare behöver lösenord - + Spectators can &chat Åskådare kan &chatta - + Spectators can see &hands Åskådare kan se spelares händer - + Create game as spectator - + Spectators Åskådare - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear Tydlig - + Create game Skapa spel - + Game information Spelinformation - + Error Fel - + Server error. Serverfel. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Namn: - + Token Jetong - + C&olor: &Färg: - + white vit - + blue blå - + black svart - + red röd - + green grön - + multicolor multifärgad - + colorless färglös - + &P/T: &P/T: - + &Annotation: &Annotering: - + &Destroy token when it leaves the table &Förstör jetong när den lämnar bordet - + Create face-down (Only hides name) - + Token data Jetongdata - + Show &all tokens Visa alla &jetonger - + Show tokens from this &deck Visa jetonger från denna &lek - + Choose token from list Välj jetong från lista - + Create token Skapa jetong @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Ingen bild vald. @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. För att radera din nuvarande avatar, klicka "bekräfta" utan att välja en ny bild. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Leta... - + Change avatar Ändra avatar - + Open Image Öppna bild - + Image Files (*.png *.jpg *.bmp) Bildfiler (*.png *.jpg *.bmp) - + Invalid image chosen. Ogiltigt val av bild. @@ -2836,38 +3454,38 @@ För att radera din nuvarande avatar, klicka "bekräfta" utan att väl DlgEditPassword - + Old password: Ditt gamla lösenord: - + New password: Nytt lösenord: - + Confirm new password: Bekräfta ditt nya lösenord: - + Change password Ändra lösenord - - + + Error Fel - + Your password is too short. - + The new passwords don't match. Dina nya lösenord matchar inte. @@ -2875,93 +3493,93 @@ För att radera din nuvarande avatar, klicka "bekräfta" utan att väl DlgEditTokens - + &Name: &Namn: - + C&olor: &Färg: - + white vit - + blue blå - + black svart - + red röd - + green grön - + multicolor multifärgad - + colorless färglös - + &P/T: &P/T: - + &Annotation: &Annotering: - + Token data Jetongdata - - + + Add token Lägg till jetong - + Remove token Ta bort jetong - + Edit custom tokens - + Please enter the name of the token: Vänligen ange namnet på jetongen: - + Error Fel - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Det valda namnet står i konflikt med ett befintligt kort eller token. @@ -3040,7 +3658,8 @@ Se till att du aktiverar "Token" i dialogrutan "Hantera uppsättn - Hide games that have started + Hide started games + Hide games that have started @@ -3076,8 +3695,9 @@ Se till att du aktiverar "Token" i dialogrutan "Hantera uppsättn - &Creator name: - S&kaparens namn: + &Host name: + &Creator name: + @@ -3143,52 +3763,52 @@ Se till att du aktiverar "Token" i dialogrutan "Hantera uppsättn DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: &Värd: - + &Port: &Hamn: - + Player &name: - + Email: E-post: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. E-postadressen kan inte vara tom. @@ -3196,37 +3816,37 @@ Se till att du aktiverar "Token" i dialogrutan "Hantera uppsättn DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: &Värd: - + &Port: &Hamn: - + Player &name: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. Spelarens namn kan inte vara tomt. @@ -3234,94 +3854,127 @@ Se till att du aktiverar "Token" i dialogrutan "Hantera uppsättn DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: &Värd: - + &Port: &Hamn: - + Player &name: - + Token: Tecken: - - + + New Password: Nytt lösenord: - + Reset Password - + The player name can't be empty. Spelarens namn kan inte vara tomt. - - - - + + + + Reset Password Error - + The token can't be empty. Token kan inte vara tomt. - + The new password can't be empty. Det nya lösenordet kan inte vara tomt. - + Error - + Your password is too short. - + The passwords do not match. Lösenorden stämmer inte överens. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3405,165 +4058,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: Värd: - + &Port: Port: - + Player &name: Spelarnamn: - + P&assword: Lösenord: - + Password (again): Lösenord (igen): - + Email: E-post: - + Email (again): E-post (igen): - + Country: Land: - + Undefined Odefinierad - + Real name: Riktiga namn: - + Register to server Registrera på server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Registreringsvarning - + Your password is too short. - + Your passwords do not match, please try again. Dina lösenord matchar inte, var god försök igen. - + Your email addresses do not match, please try again. Dina e-postadresser matchar inte, var god försök igen. - + The player name can't be empty. Fältet för spelarnamn kan inte lämnas tomt. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3629,46 +4683,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Ett okänt fel har inträffat vid laddande av kortdatabasen - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - Din kortdatabs är ogiltig. - -Cockatrice kan möjligen funger dåligt eller fel med en ogiltig databas. - -Möjligen kan du behöva starta om oracle för att uppdatera din kortdatabas. - -Vill du ändra dina databaslokaliseringsinställningar? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Din kortdatabas är för gammal. - -Detta kan skapa problem med laddandet av kortinformation eller bilder - -Detta kan oftast fixas genom att starta om oracle för att uppdatera din kortdatabas. - -Vill du ändra dina kortdatabaslokaliseringsinställningar? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3677,7 +4697,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3686,7 +4706,7 @@ Would you like to change your database location setting? Vill du ändra dina kortdatabaslokaliseringsinställningar? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3695,7 +4715,7 @@ Would you like to change your database location setting? Vill du ändra dina kortdatabaslokaliseringsinställningar? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3704,104 +4724,235 @@ Would you like to change your database location setting? - - - + + + Error Fel - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Sökvägen till din lekkatalog är ogiltig. Vill du gå tillbaka och ange den korrekta sökvägen? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Sökvägen till din kortbildsdatabas är ogiltig. Vill du gå tillbaka och ange den korrekta sökvägen? - + Settings Inställningar - + + Search settings... + + + + + OK + + + + General Allmänt - + Appearance Utseende - + User Interface Gränssnitt - + Storage - + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Chatt - + Sound Ljud - + Shortcuts Genvägar + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3809,17 +4960,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Nästa - + Previous Tidigare - + Tip of the Day Dagens Tips @@ -3827,165 +4978,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel Nuvarande släppkanal - + Reinstall Installera - + Cancel Download Avbryt Ladda ner - + Open Download Page Öppna nedladdningssidan - + Check for Client Updates - - - - + + + + Error Fel - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. Cockatrice byggdes inte med SSL-stöd, därför kan du inte ladda ner uppdateringar automatiskt! Besök sidan för nedladdning för att uppdatera manuellt. - + Downloading update: %1 - + Checking for updates... Letar efter nya uppdateringar... - + Finished checking for updates Slutfört efter uppdateringar - + No Update Available Ingen uppdatering tillgänglig - + Cockatrice is up to date! Cockatris är aktuell! - + You are already running the latest version available in the chosen release channel. Du kör redan den senaste versionen i den valda utgåvan. - + Current version Aktuell version - + Selected release channel Vald utgivningskanal - - + + Update Available Uppdatering tillgänglig - - + + A new version of Cockatrice is available! En ny version av Cockatrice är tillgänglig! - - + + New version Ny version - - + + Released Släppte - - + + Changelog Ändringslogg - + Do you want to update now? Vill du uppdatera nu? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Uppdateringsfel - + An error occurred while checking for updates: Ett fel uppstod när du letade efter uppdateringar: - + An error occurred while downloading an update: Ett fel uppstod när du hämtade en uppdatering: - + Installing... Installerar... - + Cockatrice is unable to open the installer. Cockatrice kan inte öppna installatören. - + Try to update manually by closing Cockatrice and running the installer. Försök att uppdatera manuellt genom att stänga Cockatrice och köra installationsprogrammet. - + Download location Hämta plats @@ -3993,17 +5154,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing Tydlig logg vid stängning - + Copy to clipboard - + Debug Log Felsökningslogg @@ -4241,7 +5402,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4249,7 +5410,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Skriv ditt filter här @@ -4257,45 +5418,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4303,140 +5507,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Fel - + Please join the appropriate room first. Vänligen anslut till det lämpliga rummet först. - + Wrong password. Fel lösenord. - + Spectators are not allowed in this game. Åskådare är ej tillåtna i detta spelet. - + The game is already full. Spelet är redan fullt. - + The game does not exist any more. Det här spelet finns inte längre. - + This game is only open to registered users. Det här spelet är bara öppet för registrerade användare. - + This game is only open to its creator's buddies. Det här spelet är bara öppet för skaparens vänner. - + You are being ignored by the creator of this game. Spelets skapare ignorerar dig. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game Anslut till spel - - Password: - Lösenord: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Vänligen anslut till respektive rum först. - + &Filter games Specificera speltyp - + C&lear filter Rensa filter - + C&reate Skapa - + &Join Anslut - + Join as judge - + J&oin as spectator Anslut som &åskådare - + Join as judge spectator - + Games shown: %1 / %2 - + Games Spel @@ -4477,105 +5697,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password lösenord - + buddies only endast vänner - + reg. users only endast reg. användare - + open decklists - - + + can chat kan chatta - + see hands visa händer - + can see hands Tillåts se händer - + not allowed ej tillåtna - + Room Rum - + Age Ålder - + Description Beskrivning - - Creator - Skapare + + Host + - + Type Typ - + Restrictions Begränsningar - + Players Spelare - + Spectators Åskådare @@ -4583,158 +5803,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + Svenska (Swedish) + + + + + + + + + Choose path Välj sökväg - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: Språk: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Sökvägar (redigering inaktiverad i bärbart läge) - + Paths Sökvägar - + How to help with translations - + Decks directory: Lekkatalog: - + Filters directory: - + Replays directory: Repriskatalog: - + Pictures directory: Bildkatalog: - + Card database: Kortdatabas: - + Custom database directory: - + Token database: Token-databas: - + Update channel Uppdatera kanal - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Meddela om en funktion som stöds av servern saknas i min klient - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup Visa tips vid uppstart - + Last update check on %1 (%2 days ago) @@ -4742,47 +6079,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4876,59 +6213,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5146,410 +6777,431 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Spelare %1 - + Load replay Ladda repris - + About Cockatrice Om Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: Projektledare: - + Past Project Managers: Tidigare projektledare: - + Developers: utvecklare: - + Our Developers Våra utvecklare - + Help Develop! - + Translators: Översättare: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - + + Error Fel - + &Connect... &Anslut... - + &Disconnect &Frånkoppla - + Start &local game... Starta &lokalt spel... - + &Watch replay... &Titta på repris... - + &Full screen &Fullskärmsläge - + &Register to server... - + &Restore password... - + &Settings... &Inställningar... - + &Exit A&vsluta - + A&ctions - + &Cockatrice &Cockatrice - + C&ard Database - + &Manage sets... &Hantera utgåvor... - + Edit custom &tokens... Redigera egna &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help &Hjälp - + &About Cockatrice &Om Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + Är du säker? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Ladda utgåvor/kort - + Selected file cannot be found. Den valda filen kunde inte laddas. - + You can only import XML databases at this time. För tillfället går det bara att importera XML-databaser. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. Det gick inte att importera utgåvor/kort. @@ -5754,615 +7406,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play - + from their graveyard - + from exile från exil - + from their hand - + the top card of %1's library det översta kortet av %1's lek - + the top card of their library - + from the top of %1's library från toppen av %1's lek - + from the top of their library - + the bottom card of %1's library det sista kortet i %1's lek - + the bottom card of their library - + from the bottom of %1's library från botten av %1's lek - + from the bottom of their library - + from %1's library från %1'a lek - + from their library - + from sideboard från sidbrädan - + from the stack från stapeln - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 håller nu det översta kortet %2 avslöjat. - + %1 is not revealing the top card %2 any longer. %1 håller inte längre det översta kortet %2 avslöjat. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 fäster %2 på %3s %4. - + %1 has conceded the game. - + %1 has unconceded the game. %1 har ångrat att ge upp spelet. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card ett kort - + %1 gives %2 control over %3. %1 ger kontroll över %3 till %2. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 sätter %2 i spel%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 sätter %2%3 i exil. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. %1 lägger %2%3 i sitt bibliotek %4 kort från toppen. - + %1 moves %2%3 to sideboard. %1 flyttar %2%3 till sidbrädan. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 spelar %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1 vänder %2 nedåt. - + %1 turns %2 face-up. %1 vänder %2 uppåt. - + The game has been closed. Spelet har stängts. - + The game has started. Spelet har börjat. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. %1 kollar nu på spelet. - + You have been kicked out of the game. Du har blivit utsparkad från spelet. - + %1 has left the game (%2). %1 har lämnat spelet (%2) - + %1 is not watching the game any more (%2). %1 tittar inte längre på spelet (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. %1 blandar sin lek och drar en ny hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards kort - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1's tur - + %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6370,115 +8022,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Lägg till meddelande - - + + Message: Meddelande: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - + Ignore private messages sent by non-buddy users - - + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6486,42 +8148,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6600,148 +8262,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6864,30 +8512,21 @@ Cockatrice will now reload the card database. Slutsteget - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7006,16 +8645,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7036,12 +8931,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7049,25 +8944,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7098,28 +9033,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7135,95 +9070,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -7231,17 +9174,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) - + All files (*.*) Alla filer (*.*) - + Cockatrice replays (*.cor) Cockatricerepriser (*.cor) @@ -7261,17 +9204,22 @@ This setting means you'll only see the default printing for each card, instead o - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7282,7 +9230,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7293,100 +9241,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults @@ -7394,121 +9352,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Namn - + ID ID - + Upload time Uppladdningstidpunkt + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Namn - + Players Spelare - + Keep Behåll - + Time started Tid startad - + Duration (sec) Varaktighet (sek) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7550,37 +9576,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Rum - + Joi&n &Anslut - + Room Rum - + Description Beskrivning - + Permissions - + Players Spelare - + Games Spel @@ -7588,7 +9614,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7596,32 +9622,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7654,56 +9680,92 @@ Fine-tune individual colours in the grid afterwards. + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7757,12 +9819,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7770,27 +9832,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7798,48 +9860,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7847,157 +9909,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8005,17 +10080,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8136,118 +10227,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8260,42 +10351,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8308,52 +10399,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info - + Deck - + Filters - + &View - + Card Database - + Printing - + Visible - + Floating - + Reset layout - + Deck: %1 Lek: %1 @@ -8361,49 +10452,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8411,22 +10502,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8434,138 +10525,236 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Lokalt filsystem - + Server deck storage Serverns leklagring - - + + Open in deck editor Öppna i lekredigeraren - + Rename deck or folder - + Upload deck Ladda upp lek - + Download deck Ladda ner lek - - - - + + + + New folder Ny mapp - - + + Delete Radera - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name Ange leknamn - + This decklist does not have a name. Please enter a name: Denna leklista har inget namn. Vänligen ange ett namn: - + Unnamed deck Namnlös lek - + Failed to upload deck to server - + Delete local file Radera lokal fil - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Namn på den nya mappen: - + Deck Storage @@ -8573,20 +10762,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8599,27 +11039,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8632,191 +11072,207 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + + Invite + + + + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases &Faser - + &Game &Spel - + Next &phase Nästa &fas - + Next phase with &action - + Next &turn Nästa &tur - + Reverse turn order - + &Remove all local arrows Ta &bort alla lokala pilar - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information Spel&information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede &Ge upp - + &Leave game &Lämna spel - + C&lose replay S&täng repris - + &Focus Chat - + &Say: S&äg: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede Ge upp - + Are you sure you want to concede this game? Är du säker på att du vill ge upp detta spel? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Lämna spel - + Are you sure you want to leave this game? Är du säker på att du vill lämna detta spel? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8832,157 +11288,157 @@ Please enter a name: TabLog - + Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs - + Chat Logs - - + + Error - + You must select at least one filter. - + You have to select a valid number of days to locate. - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8990,218 +11446,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat - + &Leave &Lämna - + %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1 har lämnat servern. - + %1 has joined the server. %1 har anslutit sig till servern. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Lokalt filsystem - + Server replay storage Serverns reprislagring - - + + Watch replay Titta på repris - + Rename - - + + New folder - - + + Delete Radera - + Open replays folder - + Download replay Ladda ner repris - + Toggle expiration lock Växla utgångslås - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file Radera lokal fil - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Radera avlägsen repris @@ -9211,65 +12007,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Säg: - + Chat Chatt - + &Room &Rum - + &Leave room &Lämna rum - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. Du översvämmar chatten. Var vänlig vänta ett par sekunder. @@ -9277,35 +12486,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server - - - - + + + + Error - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9313,97 +12522,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? - + There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9411,38 +12696,38 @@ To update your client, go to Help -> Check for Updates. Servern har skickat dig ett meddelande som din klient inte förstår. Det här meddelandet kan betyda att det finns en ny version av Cockatrice tillgänglig eller så kör den här servern en användarinställd eller förhandsutgiven version. För att uppdatera din klient, gå till Hjälp -> Leta efter uppdateringar. - + Idle Timeout - + You are about to be logged out due to inactivity. Du kommer snart loggas ut på grund av inaktivitet. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned Varnad - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -9461,6 +12746,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9475,6 +12788,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9544,57 +12916,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9602,216 +12969,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details Användar&detaljer - + Private &chat - + Show this user's &games Visa denna användarens &spel - - - Add to &buddy list - Lägg till som &vän - - - - Remove from &buddy list - Ta bort som &vän - - - - Add to &ignore list - &Ignorera - - - - Remove from &ignore list - Sluta &ignorera - - - - Kick from &game - &Sparka från spelet - - - - Warn user - - - View user's war&n history + View this user's &public decks - Ban from &server - &Bannlys från servern + Add to &buddy list + Lägg till som &vän - View user's &ban history - + Remove from &buddy list + Ta bort som &vän - &Promote user to moderator - + Add to &ignore list + &Ignorera - Dem&ote user from moderator - + Remove from &ignore list + Sluta &ignorera - Promote user to &judge - + Kick from &game + &Sparka från spelet - Demote user from judge + Report user + Warn user + + + + + View user's war&n history + + + + + Ban from &server + &Bannlys från servern + + + + View user's &ban history + + + + + &Promote user to moderator + + + + + Dem&ote user from moderator + + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games %1's spel - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9849,61 +13266,61 @@ Please refrain from engaging in this activity or further actions may be taken ag Administratör - + Moderator Moderator - + Registered user Registrerad användare - - + + Unregistered user Oregistrerad användare - + Judge - + Unknown - + The entered password does not match your account. - - - - + + + + Information - + User information updated. - - - - - - + + + + + - - - + + + + Error @@ -9927,283 +13344,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. - - - Password changed. - - - This server does not permit you to change your password. + Password changed. - The new password is too short. + This server does not permit you to change your password. + The new password is too short. + + + + The old password is incorrect. - + Avatar updated. - + This server does not permit you to update your avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10211,223 +13637,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings Allmänna gränssnittsinställningar - + &Double-click cards to play them (instead of single-click) &Dubbelklicka på kort för att spela dem (istället för enkelklick) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - - Use tear-off menus, allowing right click menus to persist on screen + + + Enabled - - Keep game chat focused when clicking in game (Note: disables card view search bar) - - - - - Notifications settings - - - - - Enable notifications in taskbar - - - - - Notify in the taskbar for game events while you are spectating + + + Automatic + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Use tear-off menus, allowing right click menus to persist on screen + + + + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + + Notifications settings + + + + + Enable notifications in taskbar + + + + + Notify in the taskbar for game events while you are spectating + + + + Notify in the taskbar when users in your buddy list connect - + Animation settings Animationsinställningar - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &Tappnings/Upptappningsanimation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10464,22 +14011,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10487,72 +14034,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10654,7 +14201,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10662,19 +14209,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10720,28 +14267,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10749,43 +14296,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10801,12 +14348,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10814,17 +14361,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10832,7 +14379,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10840,52 +14387,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10893,7 +14445,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10901,22 +14453,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10924,17 +14476,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10942,177 +14499,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - - How to use custom card art + + Search by set name, code, type, or release date + How to use custom card art + + + + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -11120,93 +14702,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing blanda när du stänger - + pile view - - i18n - - - English - Svenska (Swedish) - - main - + Connect on startup - + Debug to file @@ -11990,6 +15564,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard Kyrkogård + + + Report Queue + + + + + Moderation + + From 890bc0d4a490143d45715fc362efc196f37b8a0d Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:30:35 +0200 Subject: [PATCH 068/247] Translate cockatrice_en@source.ts in zh-Hant [Manual Sync] (#7572) 35% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'zh-Hant'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_zh-Hant.ts | 7446 ++++++++++++----- 1 file changed, 5515 insertions(+), 1931 deletions(-) diff --git a/cockatrice/translations/cockatrice_zh-Hant.ts b/cockatrice/translations/cockatrice_zh-Hant.ts index 2ce4cbd53..0dad917b0 100644 --- a/cockatrice/translations/cockatrice_zh-Hant.ts +++ b/cockatrice/translations/cockatrice_zh-Hant.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... 設置指示物為... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 設置指示物 - + New value for counter '%1': 新的指示物數值'%1': @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error 錯誤 - + Could not create themes directory at '%1'. - + Theme settings 主題設定 - + Current theme: 當前主題 - + Open themes folder 打開主題文件夾 - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering 牌面 - + Display card names on cards having a picture 顯示有圖卡牌的名稱 - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over 卡牌隨指針縮放 - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 手牌區域佈局 - + Display hand horizontally (wastes space) 水平顯示手牌區域 (浪費空間) - + Enable left justification 開啓左對齊 - + Table grid layout 表格佈局 - + Invert vertical coordinate 反轉垂直坐標 - + Minimum player count for multi-column layout: 界面佈局之內能夠容納的玩家欄數量: - + Maximum font size for information displayed on cards: 卡牌上顯示的最大字號 @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 禁止該用戶 - + ban &IP address 禁止該IP地址 - + ban client I&D 禁止該客户端 - + Ban type 禁止類型 - + &permanent ban 永久禁止 - + &temporary ban 暫時禁止 - + &Days: 天: - + &Hours: 小時: - + &Minutes: 分鐘: - + Duration of the ban 持續時長 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 請輸入禁止原因. 此項為管理緣由而保存並且對於這位被禁止的用戶不可見. - + Please enter the reason for the ban that will be visible to the banned person. 請輸入對於被禁止用戶可見的禁止原因 - + Redact all messages from this user in all rooms 修改該用戶在所有房間中的所有消息 - + &OK 確認 - + &Cancel 取消 - + Ban user from server 禁止此用戶 - - - - + + + + Error 錯誤 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 你必須選擇姓名、IP、客户端或任意多選組合來執行禁止操作。 - + You must have a value in the name ban when selecting the name ban checkbox. 在禁用姓名時必須填寫一個姓名。 - + You must have a value in the ip ban when selecting the ip ban checkbox. 在禁用IP時必須填寫一個IP。 - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 在禁用客戶端ID時必須填寫一個客戶端ID。 @@ -499,22 +634,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. 未收到更新伺服器的回應。 - + Invalid reply received from the release update server. 收到更新伺服器的無效回應 - + No reply received from the file update server. 未收到文件更新伺服器的回應 @@ -522,7 +657,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name 名稱 - + Sets 系列 - + Mana cost 魔法力費用 - + Card type 卡牌類別 - + P/T 力量/防禦力 - + Color(s) 顏色 + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -744,22 +1036,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -767,27 +1059,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -795,129 +1087,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ This is only saved for moderators and cannot be seen by the banned person. CardSizeWidget - + Card Size @@ -1078,6 +1370,24 @@ This is only saved for moderators and cannot be seen by the banned person. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers 更新預覽 - + Success 成功 - + Download URLs have been reset. 下載URL已重置。 - + Add URL 添加URL - - + + URL: URL: - - + + Edit URL 編輯URL - + Add New URL 添加新的URL - + Remove URL 移除URL - + Updating... 更新中... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path 選擇路徑 - + URL Download Priority URL下載優先級 - + Spoilers 預覽 - + Download Spoilers Automatically 自動下載預覽 - + Spoiler Location: 預覽位置: - + Last Change 最近的變更 - + Spoilers download automatically on launch 自動下載預覽運行 - + Press the button to manually update without relaunching 按下按鈕手動更新而不重新啓動 - + Do not close settings until manual update is complete 手動更新完成之前,請勿關閉設置 - + Download card pictures on the fly 即時下載卡牌圖片 - + How to add a custom URL 如何添加自定義URL - + Reset Download URLs 重置用於下載的URL @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number 數量 - + Provider ID - + Card 卡牌 @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... 讀取套牌... - + Load remote deck... 載入伺服器上的套牌... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 準備好開始 - + Force start - + Sideboard unlocked 解鎖備牌 - + Sideboard locked 鎖定備牌 - - + + Error 錯誤 - + The selected file could not be loaded. 此文件無法被載入 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. 下載中... - + Known Hosts 已知主機: - + Delete the currently selected saved server - + Refresh the server list with known public servers 從已知的伺服器更新伺服器列表 - + New Host 新主機 - + Name: 名稱: - + &Host: 主機: - + &Port: 端口: - + Player &name: 玩家名字: - + P&assword: 密碼: - + &Save password 記住密碼 - + A&uto connect 自動連接 - + Automatically connect to the most recent login when Cockatrice opens 當Cockatrice啓動時自動連接到最近的登錄 - + If you have any trouble connecting or registering then contact the server staff for help! 如果你在連接或者註冊上遇到了任何麻煩,可以聯繫伺服器的工作人員獲得幫助。 - - + + Webpage 網頁 - + Reset Password 重設密碼 - + Forgot password? - + &Connect 連接 - + Server 伺服器 - + Login 登錄 - + Server Contact 聯繫伺服器 - + Connect to Server 連接伺服器 - + Server URL 伺服器URL - + Communication Port 通信端口 - + Unique Server Name 唯一的伺服器名稱 - + Connection Warning 連接警告 - + You need to name your new connection profile. 你需要給連接配置文件命名。 - + Connect Warning 連接警告 - + The player name can't be empty. 玩家名稱不能為空白 @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings 記住設定 - + &Description: 描述: - + P&layers: 玩家: - + General 綜合 - + Game type 房間類型 - + &Password: 密碼: - + Only &buddies can join 只容許好友加入 - + Only &registered users can join 只容許註冊用戶加入 - + Joining restrictions 加入條件 - + &Spectators can watch 允許觀看者 - + Spectators &need a password to watch 觀看者需要密碼 - + Spectators can &chat 觀看者可以聊天 - + Spectators can see &hands 觀看者可見手牌 - + Create game as spectator 以旁觀者的身份創建遊戲 - + Spectators 觀看者 - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear 清除 - + Create game 創建一個房間 - + Game information 房間信息 - + Error 錯誤 - + Server error. 伺服器錯誤. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: 名稱: - + Token 衍生物 - + C&olor: 顏色: - + white 白 - + blue 藍 - + black 黑 - + red 紅 - + green 綠 - + multicolor 多色 - + colorless 無色 - + &P/T: 力量/防禦力: - + &Annotation: 註釋: - + &Destroy token when it leaves the table 此衍生物離場時會被摧毀 - + Create face-down (Only hides name) - + Token data 衍生物數據資料 - + Show &all tokens 顯示所有衍生物 - + Show tokens from this &deck 顯示此套牌所使用的衍生物 - + Choose token from list 從列表中選擇衍生物 - + Create token 派出一個衍生物 @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. 未選擇圖像 @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. 要清除你當前的頭像,可以不用選擇新頭像直接確認。 - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... 瀏覽... - + Change avatar 改變頭像 - + Open Image 打開圖片 - + Image Files (*.png *.jpg *.bmp) 圖片文件(*.png *.jpg *.bmp格式) - + Invalid image chosen. 所選圖片不可用。 @@ -2836,38 +3454,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: 舊密碼: - + New password: 新密碼: - + Confirm new password: 確認新密碼: - + Change password 修改密碼: - - + + Error 錯誤 - + Your password is too short. 你的密碼太短。 - + The new passwords don't match. 兩次輸入的新密碼不一致。 @@ -2875,93 +3493,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: 名稱: - + C&olor: 顏色: - + white 白 - + blue 藍 - + black 黑 - + red 紅 - + green 綠 - + multicolor 多色 - + colorless 無色 - + &P/T: 力量/防禦力: - + &Annotation: 註釋: - + Token data 衍生物數據資料 - - + + Add token 添加衍生物 - + Remove token 刪除衍生物 - + Edit custom tokens 編輯自定義衍生物 - + Please enter the name of the token: 請輸入衍生物名稱: - + Error 錯誤 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 所選擇的名字與現有的卡牌或衍生物有衝突。 @@ -3040,7 +3658,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started + Hide started games + Hide games that have started @@ -3076,8 +3695,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - 創建者名字: + &Host name: + &Creator name: + @@ -3143,52 +3763,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning 重設密碼驗證警告 - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: 主機: - + &Port: 端口: - + Player &name: 玩家名字: - + Email: 電子郵箱: - + Reset Password Challenge 重設密碼驗證 - + Reset Password Challenge Error 重設密碼驗證錯誤 - + The email address can't be empty. 電郵不能為空白 @@ -3196,37 +3816,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: 主機: - + &Port: 端口: - + Player &name: 玩家名字: - + Reset Password Request 重設密碼請求 - + Reset Password Error 重設密碼錯誤 - + The player name can't be empty. 玩家名稱不能為空白 @@ -3234,94 +3854,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning 重設密碼警告 - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: 主機: - + &Port: 端口: - + Player &name: 玩家名字: - + Token: 驗證碼 - - + + New Password: 新密碼: - + Reset Password - + The player name can't be empty. 玩家名稱不能為空白 - - - - + + + + Reset Password Error - + The token can't be empty. 驗證碼不能為空白 - + The new password can't be empty. 新密碼不能為空白 - + Error 錯誤 - + Your password is too short. 你的密碼太短。 - + The passwords do not match. 密碼不一致 + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3405,165 +4058,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: 主機: - + &Port: 端口: - + Player &name: 玩家名字: - + P&assword: 密碼: - + Password (again): (再次)輸入密碼: - + Email: 電子郵箱: - + Email (again): (再次)輸入電郵: - + Country: 國家: - + Undefined 未定義 - + Real name: 真實姓名: - + Register to server 註冊到伺服器 - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning 註冊警告 - + Your password is too short. 你的密碼太短。 - + Your passwords do not match, please try again. 兩次輸入的密碼不一致,請重新輸入, - + Your email addresses do not match, please try again. 兩次輸入的電子郵箱不一致,請重新輸入。 - + The player name can't be empty. 玩家名稱不能為空白 + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3629,46 +4683,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 讀取卡牌數據庫時出現未知錯誤 - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - 你的卡牌資料庫無效。 - -資料庫無效可能會導致Cockatrice無法正確運行。 - -你應該重新啓動Oracle以便更新卡牌資料庫。 - -你想要重新設置卡牌資料庫路徑嗎? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - 你的卡牌資料庫過舊。 - -這可能會導致卡牌圖片出現問題。 - -你應該重新啓動Oracle以便更新卡牌資料庫。 - -你想要重新設置卡牌資料庫路徑嗎? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3677,7 +4697,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3686,7 +4706,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌數據庫路徑嗎? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3695,7 +4715,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3704,104 +4724,235 @@ Would you like to change your database location setting? - - - + + + Error 錯誤 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 你的套牌路徑無效。你想要重新設置正確的路徑嗎? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 你想要重新設置卡牌數據庫路徑嗎? - + Settings 設定 - + + Search settings... + + + + + OK + + + + General 常規 - + Appearance 外觀 - + User Interface 用戶界面 - + Storage - + Card Sources 卡牌的來源 - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat 聊天 - + Sound 聲音 - + Shortcuts 快捷鍵 + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3809,17 +4960,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next 下一個 - + Previous 已知 - + Tip of the Day 每日提示 @@ -3827,165 +4978,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel 當前的發行通道 - + Reinstall 重新安裝 - + Cancel Download 取消下載 - + Open Download Page 打開下載頁面 - + Check for Client Updates - - - - + + + + Error 錯誤 - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. 雞蛇不支持建立SSL連接,因此你不能自動下載更新! 請訪問下載頁面以手動更新。 - + Downloading update: %1 - + Checking for updates... 正在檢查更新... - + Finished checking for updates 更新檢查完成 - + No Update Available 沒有更新可用 - + Cockatrice is up to date! 雞蛇已經是最新的了! - + You are already running the latest version available in the chosen release channel. 你運行的已經是最新的發行版本了。 - + Current version 當前版本 - + Selected release channel 選擇發行渠道 - - + + Update Available 更新可用 - - + + A new version of Cockatrice is available! 雞蛇有新版本可用! - - + + New version 新版本 - - + + Released 發佈 - - + + Changelog 更新日誌 - + Do you want to update now? 你打算現在就更新嗎? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error 更新錯誤 - + An error occurred while checking for updates: 檢查更新時發生錯誤: - + An error occurred while downloading an update: 下載更新時發生錯誤: - + Installing... 正在安裝... - + Cockatrice is unable to open the installer. 雞蛇無法打開安裝程序。 - + Try to update manually by closing Cockatrice and running the installer. 嘗試關閉雞蛇運行安裝程序手動更新。 - + Download location 下載區域信息 @@ -3993,17 +5154,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing 關閉時清除日誌 - + Copy to clipboard - + Debug Log 問題日誌 @@ -4241,7 +5402,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4249,7 +5410,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here 輸入你打算篩選的類型 @@ -4257,45 +5418,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4303,140 +5507,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 錯誤 - + Please join the appropriate room first. 首先請加入一個房間. - + Wrong password. 密碼錯誤. - + Spectators are not allowed in this game. 這個遊戲不允許觀眾加入. - + The game is already full. 這個遊戲已經滿了. - + The game does not exist any more. 這個遊戲不能裝下啊更多了. - + This game is only open to registered users. 這個遊戲只開放給註冊用戶. - + This game is only open to its creator's buddies. 這個遊戲只開放給好友。 - + You are being ignored by the creator of this game. 你屏蔽了這個與遊戲的創造者. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game 加入遊戲 - - Password: - 密碼: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. 請各自加入一個房間. - + &Filter games 過濾房間 - + C&lear filter 清除篩選項目 - + C&reate 創建 - + &Join 加入 - + Join as judge - + J&oin as spectator 觀看 - + Join as judge spectator - + Games shown: %1 / %2 顯示遊戲:%1/%2 - + Games 遊戲 @@ -4477,105 +5697,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new 新建 - + %1%2 min short age in minutes - + password 密碼 - + buddies only 僅限好友 - + reg. users only 僅限註冊用户 - + open decklists - - + + can chat 允許聊天 - + see hands 查看手牌 - + can see hands 允許查看手牌 - + not allowed 不允許 - + Room 房間 - + Age 時長 - + Description 描述 - - Creator - 創建者 + + Host + - + Type 類型 - + Restrictions 限制 - + Players 玩家 - + Spectators 觀看者 @@ -4583,158 +5803,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + 繁體中文 (Chinese Traditional) + + + + + + + + + Choose path 選擇路徑 - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: 語言: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) 路徑(在便攜模式下禁止編輯) - + Paths 路徑 - + How to help with translations - + Decks directory: 套牌路徑: - + Filters directory: - + Replays directory: 遊戲錄像目錄: - + Pictures directory: 圖片目錄: - + Card database: 卡牌數據庫: - + Custom database directory: - + Token database: 衍生物數據庫: - + Update channel 更新通道 - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 當客戶端缺少伺服器支持的特性時提示我 - + Automatically run Oracle when running a new version of Cockatrice 運行新版本雞蛇時自動運行Oracle - + Show tips on startup 顯示啓動時提示 - + Last update check on %1 (%2 days ago) @@ -4742,47 +6079,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4876,59 +6213,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5146,273 +6777,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 玩家 %1 - + Load replay 載入遊戲錄像 - + About Cockatrice 關於Cockatrice雞蛇 - + Version 版本 - + Cockatrice Webpage Cockatrice雞蛇網站 - + Project Manager: 項目經理: - + Past Project Managers: 前項目經理: - + Developers: 開發者: - + Our Developers 我們的開發者: - + Help Develop! 協助開發! - + Translators: 翻譯者: - + Our Translators 翻譯者名單 - + Help Translate! 協助翻譯! - + Support: 支持: - + Report an Issue 報告錯誤 - + Troubleshooting 排除故障 - + F.A.Q. 問答 - - + + Error 錯誤 - + &Connect... 連接⋯ - + &Disconnect 斷開連接 - + Start &local game... 開始本地遊戲... - + &Watch replay... 觀看錄像... - + &Full screen 全屏 - + &Register to server... 註冊到伺服器 - + &Restore password... - + &Settings... 設置⋯ - + &Exit 退出 - + A&ctions 動作 - + &Cockatrice CockatriceCockatrice - + C&ard Database 卡牌資料庫 - + &Manage sets... 系列管理 - + Edit custom &tokens... 編輯自定義&tokens - + Open custom image folder 打開自定義圖片文件夾 - + Open custom sets folder 打開自定義系列文件夾 - + Add custom sets/cards 添加自定義系列/卡牌 - + Reload card database - + Tabs - + &Help 幫助 - + &About Cockatrice 關於Cockatrice雞蛇 - + &Tip of the Day & 每日提示 - + Check for Client Updates 檢查客戶端更新 - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version 新版本 - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. 恭喜Cockatrice更新到%1! 現在啓動Oracle更新你的卡牌資料庫。 - + Cockatrice installed 雞蛇已安裝 - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. 恭喜Cockatrice更新到%1! 現在啓動Oracle更新你的卡牌資料庫。 - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database 卡牌數據庫 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5421,51 +7073,51 @@ If unsure or first time user, choose "Yes" 如果不確定或者你是第一次使用,請選擇“是” - - + + Yes 是 - - + + No 否 - + Open settings 打開設定 - + New sets found 發現新系列 - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets 查看系列 - + Welcome 歡迎 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5474,89 +7126,89 @@ Read more about changing the set order or disabling specific sets and consequent 瞭解更多用“系列管理”窗口更改系列順序或者禁用某系列的信息。 - + Information 信息 - + A card database update is already running. 數據庫更新已經在運行中。 - + Unable to run the card database updater: 無法運行卡組數據庫更新器: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards 載入系列/卡牌 - + Selected file cannot be found. 找不到選擇的文件。 - + You can only import XML databases at this time. 當前只能導入XML格式數據庫。 - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 新的系列/卡牌已添加成功。 CockatriceCockatrice現在會重新載入卡組數據庫。 - + Sets/cards failed to import. 系列/卡牌導入失敗。 @@ -5761,615 +7413,615 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 MessageLogWidget - + from play 從戰場上 - + from their graveyard 從他的墳墓場 - + from exile 從放逐區 - + from their hand 從他的手牌 - + the top card of %1's library %1牌庫頂的牌 - + the top card of their library 他牌庫頂的卡牌 - + from the top of %1's library 從%1的牌庫頂 - + from the top of their library 從他的牌庫頂 - + the bottom card of %1's library %1的牌庫底牌 - + the bottom card of their library 他牌庫底部的牌 - + from the bottom of %1's library 從%1的牌庫底 - + from the bottom of their library 從他的牌庫底 - + from %1's library 從%1的牌庫中 - + from their library 從他的牌庫 - + from sideboard 從備牌中 - + from the stack 從堆疊中 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1已持續展示牌庫頂牌%2 - + %1 is not revealing the top card %2 any longer. %1不再%2展示牌庫頂牌。 - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 結附 %2 指向 %3的 %4. - + %1 has conceded the game. %1 已放棄遊戲。 - + %1 has unconceded the game. %1 已放棄遊戲。 - + %1 has restored connection to the game. %1已恢復連接。 - + %1 has lost connection to the game. %1 已失去連接。 - + %1 points from their %2 to themselves. %1 將%2 指向自己。 - + %1 points from their %2 to %3. %1 將%2指向%3。 - + %1 points from %2's %3 to themselves. %1將%2的%3 - + %1 points from %2's %3 to %4. %1 將%2的%3指向%4。 - + %1 points from their %2 to their %3. %1將他的%2指向他的%3。 - + %1 points from their %2 to %3's %4. %1將他的%2指向%3的%4。 - + %1 points from %2's %3 to their own %4. %1將%2的%3指向他自己的%4。 - + %1 points from %2's %3 to %4's %5. %1 將%2的%3指向%4的%5。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1創建衍生物:%2%3。 - + %1 has loaded a deck (%2). %1 已載入套牌(%2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1已載入含有 %2張備牌的套牌(%3) - + %1 destroys %2. %1銷燬了%2。 - + a card 一張牌 - + %1 gives %2 control over %3. %1讓%2控制%3。 - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1將%2%3放進戰場。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1將%2%3置入他的墳墓場。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 %3放逐了 %2 - + %1 moves %2%3 to their hand. %1將%2%3放回他的手牌中。 - + %1 puts %2%3 into their library. %1將%2%3放入他的牌庫。 - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1將%2%3置於其牌庫頂。 - + %1 puts %2%3 into their library %4 cards from the top. %1將%2%3放入牌庫%4張牌放在牌庫頂 - + %1 moves %2%3 to sideboard. %1 將%2%3移動到備牌 - + %1 plays %2%3 face down. - + %1 plays %2%3. %1使用 %2%3。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. %1 正在查看%2。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1回合%2翻為面朝下。 - + %1 turns %2 face-up. %1回合%2翻為面朝上。 - + The game has been closed. 遊戲已經關閉。 - + The game has started. 遊戲已開始。 - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 已經加入遊戲。 - + %1 is now watching the game. %1 正在旁觀遊戲。 - + You have been kicked out of the game. 你已被踢出遊戲。 - + %1 has left the game (%2). %1 已經離開了遊戲(%2)。 - + %1 is not watching the game any more (%2). %1 不再旁觀遊戲(%2)。 - + %1 is not ready to start the game any more. %1 還未準備好開始遊戲。 - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. %1洗牌並抓了新的起手 - + You are watching a replay of game #%1. 你正在觀看遊戲#%1的錄像。 - + %1 is ready to start the game. %1已準備好開始遊戲。 - + cards an unknown amount of cards 牌 - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1將%2展示給%3。 - + %1 reveals %2. %1 展示 %2。 - + %1 randomly reveals %2%3 to %4. %1隨機展示%2%3給%4。 - + %1 randomly reveals %2%3. %1隨機展示%2%3。 - + %1 peeks at face down card #%2. %1查看面朝下的卡牌#%2。 - + %1 peeks at face down card #%2: %3. %1查看面朝下的卡牌#%2:%3。 - + %1 reveals %2%3 to %4. %1展示%2%3給%4。 - + %1 reveals %2%3. %1展示%2%3。 - + %1 reversed turn order, now it's %2. %1 反轉回合順序,現在是%2的回合。 - + reversed 反轉 - + normal 正常 - + Heads 正面 - + Tails 反面 - + %1 flipped a coin. It landed as %2. %1擲硬幣。結果為%2。 - + %1 rolls a %2 with a %3-sided die. %1擲%3面骰子,結果為%2。 - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. 現在是%1的回合。 - + %1 sets annotation of %2 to %3. %1給%2添加了註釋%3。 - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 將%2指示物設置為 %3 (%4%5)。 - + %1 sets %2 to not untap normally. %1 將 %2 設置為不會被通常重置。 - + %1 sets %2 to untap normally. %1 將 %2 設置為可通常重置。 - + %1 removes the PT of %2. %1 移除%2力量/防禦 - + %1 changes the PT of %2 from nothing to %4. %1改變%2的力量/防禦至%4 - + %1 changes the PT of %2 from %3 to %4. %1將%2的力量/防禦從%3改變至%4 - + %1 has locked their sideboard. %1已鎖定他的備牌。 - + %1 has unlocked their sideboard. %1 解除鎖定他的備牌。 - + %1 taps their permanents. %1 橫置了永久物。 - + %1 untaps their permanents. %1 重置了永久物。 - + %1 taps %2. %1 橫置了 %2。 - + %1 untaps %2. %1 重置了 %2。 - + %1 shuffles %2. %1 切洗了%2。 - + %1 shuffles the bottom %3 cards of %2. %1 洗 %2 底的 %3 張卡 - + %1 shuffles the top %3 cards of %2. %1 洗 %2 頂的 %3 張卡 - + %1 shuffles cards %3 - %4 of %2. %1洗%3 - %4 到%2. - + %1 unattaches %2. %1 取消了%2的結附。 - + %1 undoes their last draw. %1撤銷了最後的抓牌。 - + %1 undoes their last draw (%2). %1撤銷了最後的抓牌(%2)。 @@ -6377,115 +8029,125 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 MessagesSettingsPage - + Word1 Word2 Word3 詞1 詞2 詞3 - + Add New Message 添加新信息 - + Edit Message 編輯信息 - + Remove Message - + Add message 添加信息 - - + + Message: 信息: - + Edit message 編輯消息 - + Chat settings 聊天設定 - + Custom alert words 自定義警告語 - + Enable chat mentions 允許聊天中提到某人 - + Enable mention completer 允許自動完成提名 - + In-game message macros 遊戲內消息宏 - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users 忽略未註冊用戶的聊天室消息。 - + Ignore private messages sent by unregistered users 忽略未註冊用戶發出的私人消息 - + Ignore private messages sent by non-buddy users - - + + Invert text color 反轉文本顏色 - + Enable desktop notifications for private messages 開啓私人消息桌面提醒 - + Enable desktop notification for mentions 開啓聊天提名桌面提醒 - + Enable room message history on join 開啓加入聊天室時的歷史消息 - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (顏色為16進制) - + Separate words with a space, alphanumeric characters only 將單詞以空格區分,僅支持字母 @@ -6493,42 +8155,42 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6607,148 +8269,134 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6871,30 +8519,21 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 結束步驟 - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7013,16 +8652,272 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7043,12 +8938,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7056,25 +8951,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7105,28 +9040,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7142,95 +9077,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services 服務 - + Hide %1 隱藏 %1 - + Hide Others 隱藏其他 - + Show All 展示所有 - + Preferences... 參數 - + Quit %1 退出%1 - + About %1 關於 %1 @@ -7238,17 +9181,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) CockatriceCockatrice卡牌數據庫 (*.xml) - + All files (*.*) 全部文件 (*.*) - + Cockatrice replays (*.cor) Cockatrice錄像文件 (*.cor) @@ -7268,17 +9211,22 @@ This setting means you'll only see the default printing for each card, instead o 衍生物 - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7289,7 +9237,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7300,100 +9248,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK 確定 - + Save 保存 - + Save All 保存全部 - + Open 打開 - + &Yes 是 - + Yes to &All 全選是 - + &No 否 - + N&o to All 全選否 - + Abort 退出 - + Retry 重試 - + Ignore 忽略 - + Close 關閉 - + Cancel 取消 - + Discard 棄牌 - + Help 幫助 - + Apply 應用 - + Reset 重置 - + Restore Defaults 恢復為默認值 @@ -7401,121 +9359,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name 名稱 - + ID ID - + Upload time 上傳時間 + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name 名稱 - + Players 玩家 - + Keep 維持 - + Time started 遊戲時間 - + Duration (sec) 持續(離開) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7557,37 +9583,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms 房間 - + Joi&n 加入 - + Room 房間 - + Description 信息 - + Permissions 許可證 - + Players 玩家 - + Games 遊戲 @@ -7595,7 +9621,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7603,32 +9629,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table 從列表中選擇動作 - + Hit the key/combination of keys you want to set for this action 點擊要為此操作設置的鍵/組合鍵 - + Invalid key 無效密鑰 - + Shortcut already in use by: - + Clear 清理 - + Restore default 恢復至默認狀態 @@ -7661,56 +9687,92 @@ Fine-tune individual colours in the grid afterwards. 發佈日期 + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts 恢復所有默認快捷鍵 - + Do you really want to restore all default shortcuts? 確定要恢復所有默認快捷鍵嗎? - + Clear all default shortcuts 清楚所有默認快捷鍵 - + Do you really want to clear all shortcuts? 確定要清楚所有快捷鍵? - + Section: 部分: - + Action: 行動: - + Shortcut: 快捷鍵: - + + Shortcut editor + + + + How to set custom shortcuts 如何設置自定義快捷鍵 - + Clear all shortcuts - + Search by shortcut name @@ -7766,12 +9828,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7779,27 +9841,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 啓用聲效 - + Current sounds theme: 當前聲效主題: - + Test system sound engine 測試系統聲效 - + Sound settings 聲效設定 - + Master volume 主音量 @@ -7807,48 +9869,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended 預覽季節已結束 - + Deleting spoiler.xml. Please run Oracle 刪除spoiler.xml。請運行Oracle - - + + Spoilers download failed 預覽下載錯誤 - + No internet connection 沒有網絡連接 - + Error 錯誤 - + Spoilers already up to date Spoilers更新已準備好 - + No new spoilers added 沒有新的預覽被添加 - + Spoilers have been updated! 預覽已更新! - + Last change: 最後變更: @@ -7856,157 +9918,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. 更新伺服器未回應。 - + Invalid reply received from the release update server. 收到更新伺服器的無效回應。 - + No reply received from the tag update server. 未收到標籤更新伺服器的回應 - + Invalid reply received from the tag update server. 收到標籤更新伺服器的無效回應 + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8014,17 +10089,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8145,118 +10236,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8269,42 +10360,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8317,52 +10408,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info 卡牌信息 - + Deck 套牌 - + Filters 過濾器 - + &View 視圖 - + Card Database - + Printing - + Visible 可見 - + Floating 移動窗口 - + Reset layout 重置界面 - + Deck: %1 套牌: %1 @@ -8370,49 +10461,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8420,22 +10511,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8443,139 +10534,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 本地文件系統 - + Server deck storage 伺服器上的套牌倉庫 - - + + Open in deck editor 打開套牌編輯 - + Rename deck or folder - + Upload deck 上傳套牌 - + Download deck 下載套牌 - - - - + + + + New folder 新建文件夾 - - + + Delete 刪除 - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error 錯誤 - + Rename failed - - + + + Invalid deck file - + Enter deck name 輸入套牌名稱 - + This decklist does not have a name. Please enter a name: 這個套牌沒有名稱. 請輸入一個名稱: - + Unnamed deck 未命名套牌 - + Failed to upload deck to server - + Delete local file 刪除本地文件 - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: 新建文件夾的名稱: - + Deck Storage @@ -8583,20 +10772,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8609,27 +11049,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8642,191 +11082,207 @@ Please enter a name: TabGame - - - + + + Replay 錄像 - - + + Game 遊戲 - - + + Player List 玩家列表 - - + + + Invite + + + + + Card Info 卡牌信息 - - + + Messages 消息 - - + + Replay Timeline 錄像時間線 - + &Phases 階段 - + &Game 遊戲 - + Next &phase 下個階段 - + Next phase with &action 下個階段&行動 - + Next &turn 下個回合 - + Reverse turn order 反轉回合順序 - + &Remove all local arrows 移除所有箭頭 - + Rotate View Cl&ockwise 順時針旋轉視角 - + Rotate View Co&unterclockwise 逆時針旋轉視角 - + Game &information 遊戲信息 - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede 放棄遊戲 - + &Leave game 離開遊戲 - + C&lose replay 關閉遊戲錄像 - + &Focus Chat 聚焦聊天室 - + &Say: 説: - + Selected cards - + &View 視圖 - + Visible 可見 - + Floating 移動窗口 - + Reset layout 重置界面 - + Concede 投降 - + Are you sure you want to concede this game? 你確定放棄這個遊戲? - + Unconcede 取消投降 - + You have already conceded. Do you want to return to this game? 你已經投降,你想回到這場遊戲嗎? - + Leave game 離開遊戲 - + Are you sure you want to leave this game? 你確定離開這這個遊戲? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. 你已被踢出遊戲。 @@ -8842,158 +11298,158 @@ Please enter a name: TabLog - + Logs 記錄 - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName 時間;發送者姓名;發送者IP;消息;目標ID;目標姓名 - + Room Logs 房間記錄 - + Game Logs 遊戲記錄 - + Chat Logs 聊天記錄 - - + + Error 錯誤 - + You must select at least one filter. 您必須至少選擇一個過濾項 - + You have to select a valid number of days to locate. 您必須選擇一個可用的天數用來定位 - + Username: 用户名: - + IP Address: IP地址: - + Game Name: 遊戲名: - + GameID: 遊戲ID: - + Message: 消息: - + Main Room 主房間 - + Game Room 遊戲房間 - + Private Chat 私人聊天 - + Past X Days: 過去 X 天: - + Today 今天 - + Last Hour 1小時前 - + Maximum Results: 最大結果: - + At least one filter is required. The more information you put in, the more specific your results will be. 至少需要1個篩選項。 您輸入的信息越多,找到結果越準確。 - + Get User Logs 獲取用戶記錄 - + Clear Filters 清楚篩選項目 - + Filters 過濾器 - + Log Locations 記錄位置 - + Date Range 日期範圍 - + Maximum Results 最大結果 - - + + Message History 消息歷史 - + Failed to collect message history information. 獲取消息歷史信息失敗。 - + There are no messages for the selected filters. 沒有符合篩選條件的消息。 @@ -9001,218 +11457,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat 私人聊天 - + &Leave 離開 - + %1 - Private chat %1 - 私人聊天 - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. 該用戶已屏蔽你,他無法在主聊天中看見你的消息,你也不能加入他的遊戲。 - + Private message from 私人信息來自 - + %1 has left the server. %1 已經離開了伺服器. - + %1 has joined the server. %1 已經加入了伺服器. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system 本地文件系統 - + Server replay storage 伺服器錄像倉庫 - - + + Watch replay 觀看錄像 - + Rename - - + + New folder - - + + Delete 刪除 - + Open replays folder - + Download replay 下載遊戲錄像 - + Toggle expiration lock 過期鎖定 - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file 刪除本地文件 - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 刪除伺服器上的錄像 @@ -9222,65 +12018,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: 説: - + Chat 聊天 - + &Room 房間 - + &Leave room 離開房間 - + &Clear chat 清除聊天記錄 - + Chat Settings... 聊天設定 - + mentioned you. 提到了你。 - + Click to view 點擊查看 - + You are flooding the chat. Please wait a couple of seconds. 你正在灌水,請等待幾秒再發言. @@ -9288,35 +12497,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server 伺服器 - - - - + + + + Error 錯誤 - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9324,97 +12533,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? 你確定嗎? - + There are still open games. Are you sure you want to quit? 遊戲還在繼續, 你確定要退出嗎? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view 點擊查看 - + Your buddy %1 has signed on! 你的好友%1已登錄! - + Unknown Event 未知事件 - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9425,39 +12710,39 @@ To update your client, go to Help -> Check for Updates. 要升級你的客户端,請點擊“幫助”->“檢查更新” - + Idle Timeout 閒置超時 - + You are about to be logged out due to inactivity. 你即將因為長時間未操作而被退出。 - + Promotion 晉升 - + You have been promoted. Please log out and back in for changes to take effect. 你已被提升為版主。請退出再登陸以使修改生效。 - + Warned 被警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 你因為%1被警告。 請杜絕此類行為否則會被採取進一步行動。如果有任何疑問,請私信版主。 - + You have received the following message from the server. (custom messages like these could be untranslated) 你收到來自伺服器的如下消息。 @@ -9477,6 +12762,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9491,6 +12804,65 @@ Please refrain from engaging in this activity or further actions may be taken ag 無法分析套牌。 + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9562,57 +12934,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9620,216 +12987,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details 用户詳細信息 - + Private &chat 私人聊天 - + Show this user's &games 顯示這個用户的遊戲 - + + View this user's &public decks + + + + Add to &buddy list 加入到好友列表 - + Remove from &buddy list 從好友列表移除 - + Add to &ignore list 加入到屏蔽列表 - + Remove from &ignore list 從屏蔽列表移除 - + Kick from &game 從遊戲中踢出 - + + Report user + + + + Warn user 警告用戶 - + View user's war&n history 查看用户警告記錄 - + Ban from &server 在伺服器中禁止 - + View user's &ban history 查看用户禁止記錄 - + &Promote user to moderator 將用戶提升為版主 - + Dem&ote user from moderator 撤銷用户版主職位 - + Promote user to &judge - + Demote user from judge 撤銷用戶的裁判身份 - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games %1的遊戲 - - - + + + Ban History 禁止歷史 - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason 禁止時間;版主;禁止時長;禁止理由;可見理由 - + User has never been banned. 用戶從未被禁止 - + Failed to collect ban information. 獲取禁止信息失敗。 - - - + + + Warning History 警告歷史 - + Warning Time;Moderator;User Name;Reason 警告時間;版主;用户名;理由 - + User has never been warned. 用戶從未被警告 - + Failed to collect warning information. 獲取警告信息失敗。 - + Failed to get admin notes. - - + + Success 成功 - + Successfully promoted user. 提升用戶成功。 - + Successfully demoted user. 撤銷用戶成功。 - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed 失敗 - + Failed to promote user. 提升用戶失敗。 - + Failed to demote user. 撤銷用戶失敗。 - + Copy hash to clipboard 複製哈希到剪貼板 - + Remove this user's messages @@ -9867,61 +13284,61 @@ Please refrain from engaging in this activity or further actions may be taken ag 管理員 - + Moderator 版主 - + Registered user 註冊用戶 - - + + Unregistered user 未註冊用戶 - + Judge 裁判 - + Unknown 未知的 - + The entered password does not match your account. - - - - + + + + Information 信息 - + User information updated. 用戶信息已更新。 - - - - - - + + + + + - - - + + + + Error 錯誤 @@ -9945,283 +13362,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password 輸入密碼 - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. 該伺服器不允許你更新你的用戶信息。 - + Password changed. 密碼已修改。 - + This server does not permit you to change your password. 該伺服器不允許你修改你的密碼。 - + The new password is too short. 新密碼太短。 - + The old password is incorrect. 原密碼不正確。 - + Avatar updated. 頭像已更新。 - + This server does not permit you to update your avatar. 該伺服器不允許你更新你的頭像。 - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - 該伺服器不允許你更新你的用戶信息。 - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10229,223 +13655,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings 通用接口設定 - + &Double-click cards to play them (instead of single-click) 雙擊卡牌開始 (而不是單擊開始) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default 默認將所有非地牌加入堆疊(不是戰場) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 用卡牌信息給衍生物標注 - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen 使用浮動菜單,允許右鍵單擊菜單保留在屏幕上 - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings 通知設定 - + Enable notifications in taskbar 開啓任務欄提醒 - + Notify in the taskbar for game events while you are spectating 觀看時在任務欄提示遊戲信息 - + Notify in the taskbar when users in your buddy list connect 在任務欄中提示好友連接到伺服器 - + Animation settings 動畫設定 - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation 橫置/重置 動畫 - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10482,22 +14029,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10505,72 +14052,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10672,7 +14219,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10680,19 +14227,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10738,28 +14285,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10767,43 +14314,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10819,12 +14366,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10832,17 +14379,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10850,7 +14397,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10858,52 +14405,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10911,7 +14463,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10919,22 +14471,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10942,17 +14494,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10960,177 +14517,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 你想發出那種警告? - + Redact all messages from this user in all rooms - + &OK 確認 - + &Cancel 取消 - + Warn user for misconduct 警告用戶的不當行為 - - + + Error 錯誤 - + User name to send a warning to can not be blank, please specify a user to warn. 發送警告的用戶名不能為空,請提供用戶名以供警告。 - + Warning to use can not be blank, please select a valid warning to send. 警告不能為空,請選擇警告以供發送。 + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top 將選擇的系列移到頂端 - + Move selected set up 將選擇的系列上移 - + Move selected set down 將選擇的系列下移 - + Move selected set to the bottom 將選擇的系列移到底部 - - Search by set name, code, or type - 通過系列名、代碼或類型搜索 - - - + Default order 默認順序 - + Restore original art priority order 恢復初始優先順序 - + Enable all sets 啓用所有系列 - + Disable all sets 禁用所有系列 - + Enable selected set(s) 啓用所選系列 - + Disable selected set(s) 禁用所選系列 - + Deck Editor 套牌編輯器 - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 牌張風格 - + + Search by set name, code, type, or release date + + + + How to use custom card art 如何使用自定義卡牌圖片 - + Hints 提示 - + Note 注 - + Sorting by column allows you to find a set while not changing set priority. 按列排序允許您在不更改系列優先級的情況下查找系列。 - + To enable ordering again, click the column header until this message disappears. 要再次啓用排序,請單擊列標題,直到此消息消失。 - + Use the current sorting as the set priority instead 使用當前系列的優先級排序 - + Sorts the set priority using the same column 使用同一列系列的優先級排序 - + Manage sets 系列管理 @@ -11138,93 +14720,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing 當關閉界面時洗牌 - + pile view 柱形圖 - - i18n - - - English - 繁體中文 (Chinese Traditional) - - main - + Connect on startup 啓動連接 - + Debug to file 調試至文件 @@ -12008,6 +15582,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard 墳墓場 + + + Report Queue + + + + + Moderation + + From c9650db87e522e166a39341f526ad90687834b61 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:41:32 +0200 Subject: [PATCH 069/247] Updates for project Cockatrice and language yue (#7569) * Translate oracle_en@source.ts in yue [Manual Sync] 82% of minimum 5% translated source file: 'oracle_en@source.ts' on 'yue'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in yue [Manual Sync] 43% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'yue'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate oracle_en@source.ts in yue [Manual Sync] 82% of minimum 5% translated source file: 'oracle_en@source.ts' on 'yue'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_yue.ts | 7446 +++++++++++++++------ oracle/translations/oracle_yue.ts | 206 +- 2 files changed, 5627 insertions(+), 2025 deletions(-) diff --git a/cockatrice/translations/cockatrice_yue.ts b/cockatrice/translations/cockatrice_yue.ts index f20b9ea1d..d5769e206 100644 --- a/cockatrice/translations/cockatrice_yue.ts +++ b/cockatrice/translations/cockatrice_yue.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &設置指示物 @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 設置指示物 - + New value for counter '%1': 數值物新價值為 '%1' @@ -36,64 +36,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? 你確定嗎? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error 出錯 - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -105,12 +154,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes 更新後筆記 - + Admin Notes for %1 %1 的管理筆記 @@ -136,218 +185,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error 出現錯誤 - + Could not create themes directory at '%1'. 不能在'%1'創造主題目錄 - + Theme settings 主題設定 - + Current theme: 現在主題 - + Open themes folder 打開主題文件夾 - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings 選單設定 - + Show keyboard shortcuts in right-click menus 在右擊選單中顯示鍵盤快速鍵 - + Show game filter toolbar above list in room tab - + Card rendering 卡面算繪 - + Display card names on cards having a picture 最有圖片的牌上顯示牌名 - + Auto-Rotate cards with sideways layout 將打橫佈局卡片自動向上旋轉 - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector 將牌庫中包含的牌組推到藝術選擇視窗之頂 - + Scale cards on mouse over 卡牌隨遊標縮放 - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand 在疊區中和直行手牌中,盡量減少交重疊百分點 - + Maximum initial height for card view window: 卡望框最高開頭高度 - - + + rows 橫列 - + Maximum expanded height for card view window: - + Card counters 牌指示物 - + Counter %1 指示物 %1 - + Hand layout 手牌佈局 - + Display hand horizontally (wastes space) 打棟顯示手牌(浪費空間) - + Enable left justification 啟用向左對齊 - + Table grid layout 表格布局 - + Invert vertical coordinate 反轉垂直坐標 - + Minimum player count for multi-column layout: 界面佈局之內能夠容納的最少玩家數量: - + Maximum font size for information displayed on cards: 卡牌上顯示的最大字體大小 @@ -355,12 +490,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results 返去結果 - + Open Deck in Deck Editor 在套牌編輯&打開牌庫檔案 @@ -386,112 +521,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 禁用 &用家名字 - + ban &IP address 禁用&IP地址 - + ban client I&D 禁用客户ID - + Ban type 禁用類型 - + &permanent ban &永久禁用 - + &temporary ban &暫時禁用 - + &Days: &日 - + &Hours: &小時 - + &Minutes: &分鐘 - + Duration of the ban 禁用時間 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 請記載禁用原因 這項只保留為版主, 被禁用戶不能看到. - + Please enter the reason for the ban that will be visible to the banned person. 請記載被禁用戶能看到的被禁原因. - + Redact all messages from this user in all rooms 將這用戶所有室中打出的訊息完全移除 - + &OK &OK - + &Cancel &取消 - + Ban user from server 把用戶從本伺服器禁用 - - - - + + + + Error 出現錯誤 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 若要對用戶實施禁用,請你選擇以下三種的拼合:名字,IP,或用戶id. - + You must have a value in the name ban when selecting the name ban checkbox. 若用用戶名字施行禁用,必須要填寫姓名. - + You must have a value in the ip ban when selecting the ip ban checkbox. 若用ip地址施行禁用,必須要寫下IP地址. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 若用用戶ID施行禁用,必須要寫下用戶ID. @@ -499,22 +634,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta 測試版 - + No reply received from the release update server. 從發佈更新伺服器收不到回覆. - + Invalid reply received from the release update server. 從發佈更新伺服器收不到有效的回覆. - + No reply received from the file update server. 從檔案更新伺服器收不到有效的回覆. @@ -522,7 +657,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -530,22 +678,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -553,65 +701,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name 名字 - + Sets 牌組 - + Mana cost 法力費用 - + Card type 卡牌類別 - + P/T 力量/防御力 - + Color(s) 顏色 + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -721,22 +1013,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image 卡圖 - + Description 描述 - + Both 兩個都顯示 - + View transformation 查看轉化 @@ -744,22 +1036,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards 查看關聯卡牌 - + Add card to deck 添加卡牌到牌庫 - + Mainboard 主牌庫 - + Sideboard 副牌庫 @@ -767,27 +1059,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: 名稱: - + Set: 牌組: - + Collector Number: 收藏號碼: - + Related cards: 有關牌名: - + Unknown card: 不知名的牌 @@ -795,129 +1087,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... &展示给... - + &All players &所有玩家 - + View related cards 查看關聯卡牌 - + Token: 令牌: - + All tokens 所有令牌 - + &Select All &全選 - + S&elect Row &選擇橫行 - + S&elect Column &選擇直行 - + &Play &使出 - + &Hide &隱藏 - + Play &Face Down &面朝下使出 - + &Tap / Untap Turn sideways or back again &横置/重置 - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -938,7 +1230,7 @@ This is only saved for moderators and cannot be seen by the banned person. CardSizeWidget - + Card Size 卡牌大小 @@ -1078,6 +1370,24 @@ This is only saved for moderators and cannot be seen by the banned person.%1的自定區域'%2' + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1107,280 +1417,382 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1388,113 +1800,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1544,7 +1956,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1560,17 +1972,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1578,97 +1990,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1676,17 +2149,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1694,114 +2167,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1809,7 +2287,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1817,110 +2295,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers 更新預覽卡牌 - + Success 成功 - + Download URLs have been reset. 下載URL已重置。 - + Add URL 添加URL - - + + URL: URL: - - + + Edit URL 更改URL - + Add New URL 添置新 URL - + Remove URL 移除 URL. - + Updating... 更新中... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path 選擇路徑 - + URL Download Priority URL下載優先次序 - + Spoilers 預覽 - + Download Spoilers Automatically 自動下載預覽 - + Spoiler Location: 預覽位置: - + Last Change 最後變更 - + Spoilers download automatically on launch 開啟時自動下載預覽 - + Press the button to manually update without relaunching 免重新啟動人手按制更新 - + Do not close settings until manual update is complete 人手更新完成前,不能關掉设置 - + Download card pictures on the fly 即時下載卡牌圖片 - + How to add a custom URL 如何添加自定URL - + Reset Download URLs 重置下載URL @@ -1928,32 +2448,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1961,27 +2481,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count 數目 - + Set 牌組 - + Number 號碼 - + Provider ID 提供者 id - + Card 卡牌 @@ -2002,17 +2522,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2020,7 +2545,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2028,62 +2553,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2091,94 +2616,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2186,57 +2726,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2258,94 +2843,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... 載入牌庫... - + Load remote deck... 載入伺服器上的牌庫... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 準備好 - + Force start 強迫開始 - + Sideboard unlocked 備牌解鎖 - + Sideboard locked 解鎖鎖上 - - + + Error 出現錯誤 - + The selected file could not be loaded. 選擇的檔案不能再入 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2358,143 +2971,143 @@ This will kick all non-ready players from the game. 下載中... - + Known Hosts 已知主辦機 - + Delete the currently selected saved server 刪除現選儲藏伺服器. - + Refresh the server list with known public servers 從已知的伺服器更新伺服器列表 - + New Host 新主辦 - + Name: 名稱: - + &Host: &主辦機: - + &Port: &端口: - + Player &name: 玩家 &名字: - + P&assword: &密碼: - + &Save password &儲存密碼 - + A&uto connect &自動連接 - + Automatically connect to the most recent login when Cockatrice opens 當Cockatrice啓動時自動連接到最近的登錄 - + If you have any trouble connecting or registering then contact the server staff for help! 如在連接或註冊誰中遇到問題,請聯絡伺服器職員尋求援助! - - + + Webpage 網頁 - + Reset Password 重設密碼 - + Forgot password? 密碼忘記了? - + &Connect 連接 - + Server 伺服器 - + Login 登錄 - + Server Contact 伺服器聯絡人 - + Connect to Server 連接伺服器 - + Server URL 伺服器URL - + Communication Port 通信端口 - + Unique Server Name 獨特伺服器名字 - + Connection Warning 連接警告 - + You need to name your new connection profile. 你必須將你的連接簡介命名 - + Connect Warning 連接警告 - + The player name can't be empty. 玩家名稱不能空置 @@ -2502,122 +3115,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings &記住設定 - + &Description: &描述: - + P&layers: 玩家: - + General 一般 - + Game type 遊戲類型 - + &Password: &密碼: - + Only &buddies can join 只容許&好友加入 - + Only &registered users can join 只容許&註冊用戶加入 - + Joining restrictions 加入條件 - + &Spectators can watch 允許觀看者 - + Spectators &need a password to watch 觀看者&需要密碼 - + Spectators can &chat 觀看者可以&聊天 - + Spectators can see &hands 觀看者可見&手牌 - + Create game as spectator 以旁觀者的身份創建遊戲 - + Spectators 觀看者 - + Starting life total: 開始生命總數 - + Open decklists in lobby - + Create game as judge - + Game setup options 遊戲設定選擇 - + &Clear &清除 - + Create game 創建遊戲 - + Game information 遊戲資訊 - + Error 出現錯誤 - + Server error. 伺服器出現錯誤. @@ -2625,97 +3238,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &名稱: - + Token 令牌 - + C&olor: &顏色: - + white 白色 - + blue 藍色 - + black 黑色 - + red 紅色 - + green 綠色 - + multicolor 多色 - + colorless 無色 - + &P/T: &力量/防御力: - + &Annotation: &註釋: - + &Destroy token when it leaves the table &此令牌離場時會被摧毀 - + Create face-down (Only hides name) - + Token data 令牌資料 - + Show &all tokens 顯示&所有令牌 - + Show tokens from this &deck 顯示本牌庫所有令牌 - + Choose token from list 從清單中選擇令牌 - + Create token 派出令牌 @@ -2723,53 +3336,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2778,7 +3391,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. 未選擇圖像 @@ -2790,27 +3403,32 @@ To remove your current avatar, confirm without choosing a new image. 若要移除現有頭像, 請不要選擇新的圖片,直接確認. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... 瀏覽... - + Change avatar 更改頭像 - + Open Image 打開圖片 - + Image Files (*.png *.jpg *.bmp) 圖片文件(*.png *.jpg *.bmp格式) - + Invalid image chosen. 所選圖片不能使用。 @@ -2836,38 +3454,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: 舊密碼: - + New password: 新密碼: - + Confirm new password: 確認新密碼: - + Change password 更改密碼 - - + + Error 出現錯誤 - + Your password is too short. 你的密碼太短。 - + The new passwords don't match. 新舊密碼不一致 @@ -2875,93 +3493,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: &名稱: - + C&olor: &顏色: - + white 白色 - + blue 藍色 - + black 黑色 - + red 红色 - + green 綠色 - + multicolor 多色 - + colorless 無色 - + &P/T: &力量/防御力: - + &Annotation: &註釋: - + Token data 令牌資料 - - + + Add token 添加令牌 - + Remove token 移除令牌 - + Edit custom tokens 編輯自定令牌 - + Please enter the name of the token: 請輸入令牌名稱: - + Error 出現錯誤 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 現時選擇的名字跟現有的卡牌或令牌出現衝突.請從"牌組管理"中啟動'令牌牌組',保持正確顯示. @@ -3039,7 +3657,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started + Hide started games + Hide games that have started @@ -3075,8 +3694,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - &創建者名稱: + &Host name: + &Creator name: + @@ -3142,52 +3762,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning 重設密碼驗證警告 - + A problem has occurred. Please try to request a new password again. 程序出現故障,請再要求新的密碼. - + Enter the information of the server and the account you'd like to request a new password for. 請輸入需要新密碼的伺服器和相關戶口資料. - + &Host: &主辦機: - + &Port: &端口: - + Player &name: 玩家 &名字: - + Email: 電郵: - + Reset Password Challenge 重設密碼驗證 - + Reset Password Challenge Error 重設密碼驗證錯誤 - + The email address can't be empty. 電郵不能空置. @@ -3195,37 +3815,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. 請輸入需要新密碼的伺服器資料. - + &Host: &主辦機: - + &Port: &端口: - + Player &name: 玩家 &名字: - + Reset Password Request 重設密碼要求 - + Reset Password Error 重設密碼錯誤 - + The player name can't be empty. 玩家名稱不能空置 @@ -3233,94 +3853,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning 重設密碼警告 - + A problem has occurred. Please try to request a new password again. 程序出現故障,請再要求新的密碼. - + Enter the received token and the new password in order to set your new password. 請輸入收到驗證碼和新的密碼,以便定立新的密碼. - + &Host: &主辦機: - + &Port: &端口: - + Player &name: 玩家 &名字: - + Token: 驗證碼 - - + + New Password: 新密碼: - + Reset Password 重設密碼 - + The player name can't be empty. 玩家名稱不能空置 - - - - + + + + Reset Password Error 重設密碼錯誤 - + The token can't be empty. 驗證碼不能為空白 - + The new password can't be empty. 新密碼不能為空白 - + Error 出現錯誤 - + Your password is too short. 你的新密碼太短。 - + The passwords do not match. 新舊密碼不一致. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck 載入牌庫 @@ -3404,78 +4057,258 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): 卡牌名稱(或搜尋表達式) - + Number of hits: 因為cockatrice原生沒有ssl支援, 所以你不能自動下載更新. 請瀏覽到我們的下載網頁,進行人手更新. - + Auto play hits 開關副牌上鎖 - + Put top cards on stack until... 把頂牌放在重疊區, 直到... - + No cards matching the search expression exists in the card database. Proceed anyways? 在卡牌資料庫中找不到符合搜尋表達式的卡牌.你想繼續嗎? - + Cockatrice Cockatrice - + Invalid filter 篩選無效 + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. 輸入你, 和你想登記的伺服器的資料. @@ -3483,88 +4316,309 @@ Your email will be used to verify your account. 我們會使用你的電郵來核實你的戶口. - + &Host: &主辦機: - + &Port: &端口: - + Player &name: 玩家 &名字: - + P&assword: &密碼: - + Password (again): (再次)輸入密碼: - + Email: 電郵: - + Email (again): (再次)輸入電郵: - + Country: 國家: - + Undefined 未定義 - + Real name: 實名: - + Register to server 註冊加入伺服器 - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning 注册警告 - + Your password is too short. 你的密碼太短。 - + Your passwords do not match, please try again. 兩次輸入的密碼不一致,請重新輸入, - + Your email addresses do not match, please try again. 兩次輸入的電子郵箱不一致,請重新輸入。 - + The player name can't be empty. 玩家名稱不能空置 + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3630,46 +4684,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 讀取卡牌資料庫時出現不知名錯誤 - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - 你的卡牌資料庫無效。 - -資料庫無效可能會導致Cockatrice無法正確運行。 - -你應該重新啓動Oracle,更新卡牌資料庫。 - -你想要重新設置卡牌資料庫路徑嗎? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - 你的卡牌資料庫過舊。 - -這可能會導致卡牌圖片或資料出現問題。 - -你應該重新啓動Oracle,更新卡牌資料庫。 - -你想要重新設置卡牌資料庫路徑嗎? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3682,7 +4702,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + File Error loading your card database. Would you like to change your database location setting? @@ -3690,7 +4710,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3699,7 +4719,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3715,104 +4735,235 @@ https://github.com/Cockatrice/Cockatrice/issues 你想不想更改你的資料庫位置設定? - - - + + + Error 出現錯誤 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 你的牌庫目錄路徑現在無效. 你想要重新設置套牌路徑嗎? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 你的卡牌圖片目錄路徑現在無效. 你想要重新設置卡牌圖片資料庫路徑嗎? - + Settings 設定 - + + Search settings... + + + + + OK + + + + General 一般 - + Appearance 外觀 - + User Interface 用戶界面 - + Storage - + Card Sources 卡牌來源 - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat 聊天 - + Sound 聲音 - + Shortcuts 快捷键 + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3820,17 +4971,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next 下一個 - + Previous 上一個 - + Tip of the Day 每日提示 @@ -3838,165 +4989,175 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel 現在發行渠道 - + Reinstall 重新安裝 - + Cancel Download 取消下載 - + Open Download Page 打開下載頁面 - + Check for Client Updates 檢查客戶端更新 - - - - + + + + Error 出現錯誤 - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. 因為Cockatrice不設立ssl支援,所以你不能自動下載更新! 請你瀏覽到我們的下載網頁進行人手更新. - + Downloading update: %1 - + Checking for updates... 正在檢查更新... - + Finished checking for updates 更新檢查已完成 - + No Update Available 沒有更新可用 - + Cockatrice is up to date! Cockatrice 已經達到最新版本! - + You are already running the latest version available in the chosen release channel. 你運行的已經是最新的發行版本了。 - + Current version 現行版本 - + Selected release channel 現選發行渠道 - - + + Update Available 現有更新版本 - - + + A new version of Cockatrice is available! Cockatrice有新版本可用! - - + + New version 新版本 - - + + Released 已發行 - - + + Changelog 變更記載 - + Do you want to update now? 你打算現在更新嗎? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error 更新出現錯誤 - + An error occurred while checking for updates: 檢測更新時出現錯誤: - + An error occurred while downloading an update: 下載更新時出現錯誤 - + Installing... 安裝中 - + Cockatrice is unable to open the installer. Cockatrice 不能啟動安裝程式 - + Try to update manually by closing Cockatrice and running the installer. 請你嘗試人手更新, 關掉cockatrice, 然後重開安裝程式. - + Download location 下載位置 @@ -4004,17 +5165,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing 關閉時清除紀錄 - + Copy to clipboard - + Debug Log 偵錯紀錄 @@ -4252,7 +5413,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4260,7 +5421,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here 輸入你的篩選類型 @@ -4268,45 +5429,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4314,140 +5518,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 出現錯誤 - + Please join the appropriate room first. 請先參加合適房間 - + Wrong password. 密碼錯誤. - + Spectators are not allowed in this game. 這個遊戲不允許觀眾加入. - + The game is already full. 這個遊戲已經滿了. - + The game does not exist any more. 這個遊戲已經不存在了。 - + This game is only open to registered users. 這個遊戲只開放給註冊用戶. - + This game is only open to its creator's buddies. 這個遊戲只開放給房間主辦的好友。 - + You are being ignored by the creator of this game. 你被這個房間的主辦屏蔽了. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game 加入遊戲 - - Password: - 密碼: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. 請先參加相關的房間. - + &Filter games &篩選遊戲 - + C&lear filter &清除篩選項目 - + C&reate &創建 - + &Join &加入 - + Join as judge - + J&oin as spectator &作為觀看者加入 - + Join as judge spectator - + Games shown: %1 / %2 顯示遊戲:%1/%2 - + Games 遊戲 @@ -4488,105 +5708,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1日 - + %1%2 hr short age in hours %1%2 小時 - + new 新建 - + %1%2 min short age in minutes %1%2 分鐘 - + password 密碼 - + buddies only 僅限好友 - + reg. users only 僅限註冊用户 - + open decklists - - + + can chat 允許聊天 - + see hands 查看手牌 - + can see hands 允許查看手牌 - + not allowed 不允許 - + Room 房間 - + Age 時長 - + Description 描述: - - Creator - 創建者 + + Host + - + Type 類型 - + Restrictions 限制 - + Players 玩家 - + Spectators 觀看者 @@ -4594,158 +5814,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths 重置所有路徑 - + All paths have been reset 重置所有路徑 - - - - - - - + + + + English + 粵語 (Cantonese) + + + + + + + + + Choose path 選擇路徑 - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: 語言: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) 路徑(在便攜模式下不能編輯) - + Paths 路徑 - + How to help with translations - + Decks directory: 牌庫路徑: - + Filters directory: - + Replays directory: 遊戲錄像目錄: - + Pictures directory: 圖片目錄: - + Card database: 卡牌資料庫: - + Custom database directory: 自主資料庫目錄 - + Token database: 令牌資料庫: - + Update channel 更新渠道 - + Check for client updates on startup 啟動時檢查客戶更新 - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 如果客戶端缺少伺服器支持的更功能時,請提示我 - + Automatically run Oracle when running a new version of Cockatrice 啟動新版本Cockatrice時,自動運行Oracle. - + Show tips on startup 顯示啓動時提示 - + Last update check on %1 (%2 days ago) @@ -4753,47 +6090,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4887,59 +6224,353 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5157,273 +6788,294 @@ You may have to manually download the new version. MainWindow - - + + Player %1 玩家 %1 - + Load replay 載入遊戲錄像 - + About Cockatrice 關於Cockatrice - + Version 版本 - + Cockatrice Webpage Cockatrice 網頁 - + Project Manager: 項目經理: - + Past Project Managers: 前項目經理: - + Developers: 開發者: - + Our Developers 我們的開發者: - + Help Develop! 協助開發! - + Translators: 翻譯者: - + Our Translators 翻譯者名單 - + Help Translate! 協助翻譯! - + Support: 支援: - + Report an Issue 報告問題 - + Troubleshooting 排除故障 - + F.A.Q. 問答 - - + + Error 出現錯誤 - + &Connect... &連線... - + &Disconnect &斷線 - + Start &local game... 開始&離線遊戲 - + &Watch replay... &觀看遊戲錄像 - + &Full screen &全屏 - + &Register to server... &註冊加入伺服器 - + &Restore password... &弄回密碼 - + &Settings... 設置⋯ - + &Exit &退出 - + A&ctions &動作 - + &Cockatrice &Cockatrice - + C&ard Database &卡牌資料庫 - + &Manage sets... &管理牌組 - + Edit custom &tokens... 編輯自定 &令牌... - + Open custom image folder 打開自定圖片文件夾 - + Open custom sets folder 打開自定牌组文件夾 - + Add custom sets/cards 添加自定卡组/卡牌 - + Reload card database 刷新卡牌資料庫 - + Tabs - + &Help &求助 - + &About Cockatrice &關於Cockatrice - + &Tip of the Day &每日提示 - + Check for Client Updates 檢查客戶端更新 - + Check for Card Updates... 檢查卡牌更新 - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log 查看&偵錯紀錄 - + Open Settings Folder 打開設置文件夾 - + + Re-run Onboarding Wizard... + + + + Show/Hide 顯示/隱藏 - + + Sign-in cancelled + + + + New Version 新版本 - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. 恭喜你將Cockatrice升級為%1. 現在會幫你啟動oracle, 更新你的卡牌資料庫. - + Cockatrice installed Cockatrice 已被安裝 - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. 恭喜你成功安裝Cockatrice%1. 現在會幫你啟動oracle, 安裝你的卡牌資料庫. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database 卡牌資料庫 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5432,29 +7084,29 @@ If unsure or first time user, choose "Yes" 如果不確定, 或者是第一次使用,請選擇“是/Yes” - - + + Yes 是/Yes - - + + No 否/No - + Open settings 打開設置 - + New sets found 發現新卡组 - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5463,22 +7115,22 @@ Do you want to enable it/them? 要啟用它們嗎? - + Yes, always enable - + View sets 查看卡组 - + Welcome 歡迎 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5487,88 +7139,88 @@ Read more about changing the set order or disabling specific sets and consequent 請閱讀,了解更多在“卡组设置”對話框裡.更改卡组顺序或者禁用某些卡组的功能. - + Information 資訊 - + A card database update is already running. 資料庫更新現已進行. - + Unable to run the card database updater: 不能運行卡牌資料附更新程式: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards 载入卡组/卡牌 - + Selected file cannot be found. 找不到選擇的文件。 - + You can only import XML databases at this time. 你暫時只能導入xml資料庫. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 已經成功添加新的卡組/卡牌. Cockatrice會重新載入卡牌資料庫. - + Sets/cards failed to import. 卡組/卡牌導入失敗. @@ -5773,615 +7425,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play 從戰場上 - + from their graveyard 從它們的墳場 - + from exile 從放逐區 - + from their hand 從它們的手牌 - + the top card of %1's library %1牌庫的頂牌 - + the top card of their library 它們的頂牌 - + from the top of %1's library 從%1牌庫的頂 - + from the top of their library 從它們1牌庫頂 - + the bottom card of %1's library %1牌庫的底牌 - + the bottom card of their library 它們牌庫的底牌 - + from the bottom of %1's library 從%1的牌庫底 - + from the bottom of their library 從它們的牌庫底 - + from %1's library 從%1的牌庫 - + from their library 從它們的牌庫 - + from sideboard 從備牌中 - + from the stack 從疊區中 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1現在保持%2頂牌顯現. - + %1 is not revealing the top card %2 any longer. %1不再展示牌庫頂牌%2。 - + %1 can now look at top card %2 at any time. %1可以隨時查看牌庫頂牌 %2. - + %1 no longer can look at top card %2 at any time. %1再不可以隨時查看牌庫頂牌 %2. - + %1 attaches %2 to %3's %4. %1 結附 %2 指向 %3的 %4. - + %1 has conceded the game. %1 在此遊戲宣佈降服. - + %1 has unconceded the game. %1 收回在此遊戲降服. - + %1 has restored connection to the game. %1已恢復連接。 - + %1 has lost connection to the game. %1 已失去連接。 - + %1 points from their %2 to themselves. %1 從它們的%2 指向自己。 - + %1 points from their %2 to %3. %1 從它們的%2指向%3。 - + %1 points from %2's %3 to themselves. %1 從%2的%3 指向自己。 - + %1 points from %2's %3 to %4. %1 從%2的%3 指向%4。 - + %1 points from their %2 to their %3. %1 從它們的%2指向它們的%3。 - + %1 points from their %2 to %3's %4. %1 從它們的%2指向%3的%4。 - + %1 points from %2's %3 to their own %4. %1 從%2的%3指向自己的%4。 - + %1 points from %2's %3 to %4's %5. %1 從%2的%3指向%4的%5。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1 派出令牌: %2%3 - + %1 has loaded a deck (%2). %1 已载入牌庫(%2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1已載入含有 %2張備牌的牌庫(%3). - + %1 destroys %2. %1销毁了%2。 - + a card 一張牌 - + %1 gives %2 control over %3. %1將%3的控制轉移給%2. - + %1 puts %2 into play%3 face down. %1將%2%3牌面朝下地放進戰場。 - + %1 puts %2 into play%3. %1將%2放進戰場%3。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1將%2%3置入它們的的墳場。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 放逐%2%3. - + %1 moves %2%3 to their hand. %1將%2%3移到它們的的手牌中。 - + %1 puts %2%3 into their library. %1將%2%3放入它們的牌庫。 - + %1 puts %2%3 onto the bottom of their library. %1將%2%3放在它們的牌庫底部。 - + %1 puts %2%3 on top of their library. %1將%2%3放在它們的牌庫頂部。 - + %1 puts %2%3 into their library %4 cards from the top. %1將%2%3放在它們的牌庫頂部%4張牌以下。 - + %1 moves %2%3 to sideboard. %1 將%2%3移到副牌庫. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1使出 %2%3。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1嘗試從空牌庫抽牌 - + %1 draws %2 card(s). %1抽%2張牌。 - + %1 is looking at %2. %1 正在查看%2。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 正在查看%2的 %4 %3張卡牌. - + bottom 底部 - + top 頂部 - + %1 turns %2 face-down. %1把%2翻為面朝下。 - + %1 turns %2 face-up. %1把%2翻為面朝上。 - + The game has been closed. 遊戲已經關閉。 - + The game has started. 遊戲已開始。 - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 已經加入遊戲。 - + %1 is now watching the game. %1 正在旁觀遊戲。 - + You have been kicked out of the game. 你已被踢出遊戲。 - + %1 has left the game (%2). %1 已經離開遊戲(%2)。 - + %1 is not watching the game any more (%2). %1 已經不再旁觀遊戲(%2)。 - + %1 is not ready to start the game any more. %1 還未準備好開始遊戲。 - + %1 shuffles their deck and draws a new hand of %2 card(s). %1將其牌庫洗牌,然後抽%2張牌作為新的起手。 - + %1 shuffles their deck and draws a new hand. %1將其牌庫洗牌,然後抽起新的手牌。 - + You are watching a replay of game #%1. 你正在觀看遊戲#%1的錄像。 - + %1 is ready to start the game. %1已準備好開始遊戲。 - + cards an unknown amount of cards 卡牌(眾)  - + %1 card(s) a card for singular, %1 cards for plural %1 卡牌(眾)  - + %1 lends %2 to %3. %1把&2借給%3. - + %1 reveals %2 to %3. %1把%2展示給%3. - + %1 reveals %2. %1 展示 %2。 - + %1 randomly reveals %2%3 to %4. %1隨機展示%2%3給%4。 - + %1 randomly reveals %2%3. %1隨機展示%2%3。 - + %1 peeks at face down card #%2. %1查看面朝下的卡牌#%2。 - + %1 peeks at face down card #%2: %3. %1查看面朝下的卡牌#%2:%3。 - + %1 reveals %2%3 to %4. %1展示%2%3給%4。 - + %1 reveals %2%3. %1展示%2%3。 - + %1 reversed turn order, now it's %2. %1 逆轉回合次序,現在輪到%2。 - + reversed 逆轉 - + normal 正常 - + Heads 正面 - + Tails 翻面 - + %1 flipped a coin. It landed as %2. %1擲了硬幣。結果為%2。 - + %1 rolls a %2 with a %3-sided die. %1擲%3面骰子,結果為%2。 - + %1 flips %2 coins. There are %3 heads and %4 tails. %1擲了%2個硬幣。結果為%3個正面和%4個反面。 - + %1 rolls a %2-sided dice %3 times: %4. %1把%2面骰子擲了%3次:%4 - + %1's turn. 輪到 %1. - + %1 sets annotation of %2 to %3. %1給%2添加了註釋成%3。 - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 將%2指示物設置為 %3 (%4%5)。 - + %1 sets %2 to not untap normally. %1 將 %2 設置為不被重置。 - + %1 sets %2 to untap normally. %1 將 %2 設置為照常重置。 - + %1 removes the PT of %2. %1 移除%2的力量/防禦. - + %1 changes the PT of %2 from nothing to %4. %1把%2的力量/防禦從無數值轉為%4. - + %1 changes the PT of %2 from %3 to %4. %1把%2的力量/防禦從%3轉為%4. - + %1 has locked their sideboard. %1已經鎖定它們的的副牌庫。 - + %1 has unlocked their sideboard. %1已經把它們的的副牌庫解鎖。 - + %1 taps their permanents. %1横置它們的永久卡牌 - + %1 untaps their permanents. %1重置它們的永久卡牌. - + %1 taps %2. %1 横置 %2. - + %1 untaps %2. %1 重置 %2。 - + %1 shuffles %2. % 1 對 %2 洗牌 - + %1 shuffles the bottom %3 cards of %2. %1 洗 %2 底的 %3 張卡牌. - + %1 shuffles the top %3 cards of %2. %1 洗 %2 頂的 %3 張卡牌. - + %1 shuffles cards %3 - %4 of %2. %1到%2.洗%3 - %4 - + %1 unattaches %2. %1 取消了%2的結附。 - + %1 undoes their last draw. %1 放回它們最後一張抽到的卡牌. - + %1 undoes their last draw (%2). %1 放回它們最後一張抽到的卡牌(%2). @@ -6389,115 +8041,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 詞1 詞2 詞3 - + Add New Message 添加新信息 - + Edit Message 更改信息 - + Remove Message 移除信息 - + Add message 添加信息 - - + + Message: 信息: - + Edit message 更改信息 - + Chat settings 聊天設定 - + Custom alert words 自定通知觸發詞語 - + Enable chat mentions 允許聊天中提到某人 - + Enable mention completer 允許自動完成提名 - + In-game message macros 遊戲內短信速鍵 - + How to use in-game message macros 如何使用遊戲內短信速鍵 - + Ignore chat room messages sent by unregistered users 不要顯示未註冊用户的聊天消息。 - + Ignore private messages sent by unregistered users 不要顯示未註冊用户的私人消息. - + Ignore private messages sent by non-buddy users - - + + Invert text color 反轉文本顏色 - + Enable desktop notifications for private messages 容許桌面提醒提及私人消息 - + Enable desktop notification for mentions 容許桌面提醒提及聊天提名 - + Enable room message history on join 加入聊天室時開啓消息歷史 - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (顏色為16進制) - + Separate words with a space, alphanumeric characters only 將單詞以空格區分,僅支持字母. @@ -6505,42 +8167,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6619,148 +8281,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6883,30 +8531,21 @@ Cockatrice will now reload the card database. 回合完結部 - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7025,16 +8664,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7055,12 +8950,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7068,25 +8963,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference 喜好 - + Pin Printing 版本上別針 - + Unpin Printing 版本脫別針 - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards 顯示關聯卡牌 + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7117,28 +9052,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical 英文字母次序 - + Preference 喜好 - + Release Date 發行日期 - - + + Descending 由後到先 - + Ascending 由先倒後 @@ -7154,95 +9089,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services 服務 - + Hide %1 隐藏 %1 - + Hide Others 隱藏其他 - + Show All 全部展示 - + Preferences... 喜好... - + Quit %1 退出%1 - + About %1 關於 %1 @@ -7250,17 +9193,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatrice卡牌資料庫 (*.xml) - + All files (*.*) 所有檔案格式 (*.*) - + Cockatrice replays (*.cor) Cockatrice錄像文件 (*.cor) @@ -7280,17 +9223,22 @@ This setting means you'll only see the default printing for each card, instead o 眾令牌 - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7301,7 +9249,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7312,100 +9260,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK OK/確認 - + Save 保存 - + Save All 全部保存 - + Open 打開 - + &Yes &是/Yes - + Yes to &All &全選是/Yes - + &No &否/No - + N&o to All &全選否/No - + Abort 退出 - + Retry 重試 - + Ignore 不理 - + Close 關閉 - + Cancel 取消 - + Discard 棄牌 - + Help 協助 - + Apply 應用 - + Reset 重設 - + Restore Defaults 恢復常用認值 @@ -7413,121 +9371,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name 名字 - + ID ID - + Upload time 上載時間 + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name 名字 - + Players 玩家(眾) - + Keep 選用 - + Time started 開始時間 - + Duration (sec) 維持(秒) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7569,37 +9595,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms 房間(眾)  - + Joi&n &加入 - + Room 房間 - + Description 描述 - + Permissions 許可 - + Players 玩家(眾) - + Games 遊戲(眾) @@ -7607,7 +9633,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7615,32 +9641,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table 從列表中選擇行動 - + Hit the key/combination of keys you want to set for this action 按下以此行動設置的單鍵或組鍵 - + Invalid key 設置的單鍵或組鍵無效 - + Shortcut already in use by: 捷鍵已被:&使用 - + Clear 清除 - + Restore default 恢復常用認值 @@ -7673,56 +9699,92 @@ Fine-tune individual colours in the grid afterwards. 發行日期 + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts 恢復所有常用快鍵 - + Do you really want to restore all default shortcuts? 確定要恢復所有常用快鍵嗎? - + Clear all default shortcuts 清除所有常用快鍵 - + Do you really want to clear all shortcuts? 確認清除所有快鍵嗎? - + Section: 部分: - + Action: 行動: - + Shortcut: 快鍵: - + + Shortcut editor + + + + How to set custom shortcuts 如何設置自定快鍵 - + Clear all shortcuts 清除所有快键 - + Search by shortcut name 按快鍵名稱搜尋 @@ -7778,12 +9840,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7791,27 +9853,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 啓用&聲效 - + Current sounds theme: 現前聲效主題: - + Test system sound engine 測試系統聲效引擎 - + Sound settings 聲效設置 - + Master volume 主音量 @@ -7819,48 +9881,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended 預覽季節已結束 - + Deleting spoiler.xml. Please run Oracle 現在正刪除spoiler.xml。請啟動Oracle - - + + Spoilers download failed 預覽下載出錯 - + No internet connection 沒有網絡連接 - + Error 出現錯誤 - + Spoilers already up to date 預覽已是最新 - + No new spoilers added 沒有添加新預覽 - + Spoilers have been updated! 預覽已被更新 - + Last change: 最後變更: @@ -7868,157 +9930,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default 常用 - + No reply received from the release update server. 從發佈更新伺服器收不到有效的回覆. - + Invalid reply received from the release update server. 從發佈更新伺服器收不到有效的回覆. - + No reply received from the tag update server. 未收到標籤更新伺服器的回應. - + Invalid reply received from the tag update server. 從標籤更新伺服器收不到有效的回覆. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8026,17 +10101,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8157,118 +10248,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8281,42 +10372,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8329,52 +10420,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info 卡牌資訊 - + Deck 牌庫 - + Filters 篩選項目 - + &View &視圖 - + Card Database - + Printing 印刷版本 - + Visible 可見 - + Floating 浮動 - + Reset layout 重置布局 - + Deck: %1 牌庫:%1 @@ -8382,49 +10473,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8432,22 +10523,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8455,138 +10546,236 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 本地檔案系統 - + Server deck storage 伺服器牌庫儲藏 - - + + Open in deck editor 在套牌編輯打開 - + Rename deck or folder - + Upload deck 上載牌庫 - + Download deck 下载套牌 - - - - + + + + New folder 新文件夾 - - + + Delete 删除 - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder 打開牌庫文件夾 - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error 出現錯誤 - + Rename failed - - + + + Invalid deck file 無效牌庫檔案 - + Enter deck name 輸入牌庫名稱 - + This decklist does not have a name. Please enter a name: 本牌庫列表未有名稱.請輸入名稱: - + Unnamed deck 未命名牌庫 - + Failed to upload deck to server 牌庫上載於伺服器時失敗 - + Delete local file 删除本地檔案 - + Are you sure you want to delete the selected files? 你確定要刪除被選檔案(眾)嗎 ? - + Delete remote decks 刪除伺服器上的牌庫 - + Are you sure you want to delete the selected decks? 你確定要剷除所有被選的牌庫嗎? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: 新建文件夾名稱: - + Deck Storage @@ -8594,20 +10783,271 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8620,27 +11060,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8653,191 +11093,207 @@ Please enter a name: TabGame - - - + + + Replay 遊戲錄像重溫 - - + + Game 遊戲 - - + + Player List 玩家列表 - - + + + Invite + + + + + Card Info 卡牌資訊 - - + + Messages 信息 - - + + Replay Timeline 錄像時間線 - + &Phases 階段 - + &Game 遊戲 - + Next &phase 下個&階段 - + Next phase with &action 下個階段&行動 - + Next &turn 下個&回合 - + Reverse turn order 逆轉回合次序 - + &Remove all local arrows &移除所有自設箭頭 - + Rotate View Cl&ockwise &順時針旋轉視角 - + Rotate View Co&unterclockwise &逆時針旋轉視角 - + Game &information 遊戲&資訊 - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede 撤銷&投降 - - - + + + &Concede &投降 - + &Leave game &離開遊戲 - + C&lose replay &關閉遊戲錄像 - + &Focus Chat &聚焦聊天 - + &Say: &説: - + Selected cards - + &View &視圖 - + Visible 可見 - + Floating 浮動 - + Reset layout 重置布局 - + Concede 投降 - + Are you sure you want to concede this game? 你確定要宣佈投降嗎? - + Unconcede 撤銷投降 - + You have already conceded. Do you want to return to this game? 你已經宣佈投降,你想回到這場遊戲嗎? - + Leave game 離開遊戲 - + Are you sure you want to leave this game? 你確定要離開這個遊戲嗎? - + A player has joined game #%1 有玩家加入遊戲 #%1 - + %1 has joined the game %1 已經加入遊戲。 - + You have been kicked out of the game. 你已被踢出遊戲。 @@ -8853,158 +11309,158 @@ Please enter a name: TabLog - + Logs 記錄 - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName 時間;發送者姓名;發送者IP;信息;目標ID;目標姓名 - + Room Logs 房間記錄 - + Game Logs 遊戲記錄 - + Chat Logs 聊天記錄 - - + + Error 出現錯誤 - + You must select at least one filter. 您必須選擇至少一個過濾項. - + You have to select a valid number of days to locate. 您必須選擇一個可用的日數來作為定位. - + Username: 用戶名稱: - + IP Address: IP地址 - + Game Name: 遊戲名稱: - + GameID: 遊戲ID: - + Message: 信息: - + Main Room 主房 - + Game Room 遊戲房間 - + Private Chat 私人聊天 - + Past X Days: 過去 X 天: - + Today 今天 - + Last Hour 1小時前 - + Maximum Results: 最多結果: - + At least one filter is required. The more information you put in, the more specific your results will be. 至少需要1個篩選項。 您輸入的信息越多,找到結果越準確。 - + Get User Logs 獲取用戶記錄 - + Clear Filters 清除篩選項目 - + Filters 篩選項目 - + Log Locations 記錄位置 - + Date Range 日期範圍 - + Maximum Results 最多結果 - - + + Message History 信息歷史 - + Failed to collect message history information. 不能獲取信息歷史消息 - + There are no messages for the selected filters. 沒有符合篩選條件的信息。 @@ -9012,218 +11468,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat 私人&聊天 - + &Leave &離開 - + %1 - Private chat %1 - 私人聊天 - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. 你已被這用戶屏蔽, 它們不能再主聊天室看見你的信息,你亦不能參加它們的遊戲. - + Private message from 私人信息信到, 来自 - + %1 has left the server. %1 已離開伺服器. - + %1 has joined the server. %1 已加入伺服器. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system 本地檔案系統 - + Server replay storage 伺服器錄像倉庫 - - + + Watch replay 觀看遊戲錄像 - + Rename 改名 - - + + New folder 新文件夾 - - + + Delete 刪除 - + Open replays folder 打開錄像文件夾 - + Download replay 下載遊戲錄像 - + Toggle expiration lock 過期鎖定 - + Get replay share code - - + + Look up replay by share code - + Rename local folder 本地檔案夾改名 - + Rename local file 本地檔案改名 - + New name: 新名稱 - + Error 出錯 - + Rename failed 改名不成功 - + Name of new folder: 新建文件夾名稱: - + Delete local file 刪除本地檔案 - + Are you sure you want to delete the selected files? 你確定要剷除所有被選的檔案嗎? - + Are you sure you want to delete the selected replays? 你確定要刪除被選錄像檔案嗎 ? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 刪除伺服器錄像 @@ -9233,65 +12029,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &説: - + Chat 聊天 - + &Room &房間 - + &Leave room &離開房間 - + &Clear chat &清除聊天記錄 - + Chat Settings... 聊天設置... - + mentioned you. 提到了你。 - + Click to view 點擊查看 - + You are flooding the chat. Please wait a couple of seconds. 你正在刷屏。請稍等幾秒鐘。 @@ -9299,35 +12508,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server 伺服器 - - - - + + + + Error 出現錯誤 - + Failed to join the server room: it doesn't exist on the server. 加入伺服器房間失敗:在本伺服器上這房間並不存在。 - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. 伺服器認為你在房間內, 但Cockatrice無法顯示。請重新啓動Cockatrice。 - + You do not have the required permission to join this server room. 你未被允許進入該房間。 - + Failed to join the server room due to an unknown error: %1. 因為以下不知名的錯誤,所以不能參入伺服器房間: %1. @@ -9335,97 +12544,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? 你確定嗎? - + There are still open games. Are you sure you want to quit? 遊戲還在繼續, 你確定要退出嗎? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view 點擊查看 - + Your buddy %1 has signed on! 你的好友%1已登錄! - + Unknown Event 不知名事件 - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9436,38 +12721,38 @@ To update your client, go to Help -> Check for Updates. 要升級你的客户端,請點擊“幫助”->“檢查更新” - + Idle Timeout 閒置超時 - + You are about to be logged out due to inactivity. 你即將因為長時間沒有活動而被退出。 - + Promotion 升級 - + You have been promoted. Please log out and back in for changes to take effect. 你已被升級.請登出然後再登入讓本更改生效. - + Warned 被警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 你由於$1 原因,現在接受警告. 請避免繼續本行為, 否則你將有可能會接受進一步的警誡或懲罰. 若有任何疑問, 請私信我們門下的版主. - + You have received the following message from the server. (custom messages like these could be untranslated) 你收到松本伺服器發出的以下訊息(類似本信息的自定訊息可能未經翻譯) @@ -9486,6 +12771,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9500,6 +12813,65 @@ Please refrain from engaging in this activity or further actions may be taken ag 不能分析本牌庫. + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9571,57 +12943,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9629,216 +12996,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details 用户&詳細信息 - + Private &chat 私人&聊天 - + Show this user's &games 顯示這個用户的&遊戲 - + + View this user's &public decks + + + + Add to &buddy list 加入到&好友列表 - + Remove from &buddy list 從&好友列表移除 - + Add to &ignore list 加入到&屏蔽列表 - + Remove from &ignore list 從&屏蔽列表移除 - + Kick from &game 從&遊戲中踢出 - + + Report user + + + + Warn user 警告用戶 - + View user's war&n history 查看用户警告记录 - + Ban from &server 把用戶從&伺服器禁用 - + View user's &ban history 查看用户&禁止記錄 - + &Promote user to moderator 升$用戶作為版主 - + Dem&ote user from moderator 把&用戶從版主降級 - + Promote user to &judge 把&用戶升級成裁判 - + Demote user from judge 把&用戶從裁判降級 - + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes 閱讀管理筆記 - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games %1 的遊戲(眾) - - - + + + Ban History 禁用歷史 - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason 禁止時間;版主;禁止時長;禁止理由;可見理由 - + User has never been banned. 用戶從未被禁 - + Failed to collect ban information. 不能獲取禁用訊息 - - - + + + Warning History 警告歷史 - + Warning Time;Moderator;User Name;Reason 警告時間;版主;用戶名稱;理由 - + User has never been warned. 用戶從未接收警告 - + Failed to collect warning information. 不能獲取警告信息 - + Failed to get admin notes. 不能獲取管理筆記 - - + + Success 成功 - + Successfully promoted user. 用戶升級成功 - + Successfully demoted user. 用戶降級成功 - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed 失敗 - + Failed to promote user. 用戶升級失敗 - + Failed to demote user. 用戶降級失敗 - + Copy hash to clipboard 把哈希覆印到用戶剪貼 - + Remove this user's messages 移除這用戶訊息 @@ -9876,61 +13293,61 @@ Please refrain from engaging in this activity or further actions may be taken ag 管理員 - + Moderator 版主 - + Registered user 登記用戶 - - + + Unregistered user 未登記用戶 - + Judge 裁判 - + Unknown 不知名 - + The entered password does not match your account. 輸入密碼跟你戶口不匹配 - - - - + + + + Information 資訊 - + User information updated. 用戶資訊已更新。 - - - - - - + + + + + - - - + + + + Error 出現錯誤 @@ -9954,283 +13371,292 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) %n年(眾) , - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n日(眾) %20 - + Enter Password 輸入密碼 - + Password verification is required in order to change your email address 要核實密碼才能更改你的電郵地址 - - - + + + An error occurred while trying to update your user information. 在嘗試更新你的用戶信息時出現錯誤. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. 本伺服器不容許你更新你的用戶資訊. - + Password changed. 密碼已更改 - + This server does not permit you to change your password. 本伺服器不容許你更改密碼 - + The new password is too short. 新的密碼太短 - + The old password is incorrect. 舊的密碼不正確 - + Avatar updated. 頭像已更新。 - + This server does not permit you to update your avatar. 本伺服器不容許你更新你的頭像. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - 本伺服器不容許你更新你的用戶資訊. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10238,223 +13664,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings 一般通用接口設置 - + &Double-click cards to play them (instead of single-click) 雙點擊使出卡牌 (而不是單點擊使出) - + &Clicking plays all selected cards (instead of just the clicked card) &單一點擊把所有選擇的卡牌使出(不只是被點擊的單張卡牌) - + &Play all nonlands onto the stack (not the battlefield) by default &把所有使出的非地牌放入堆疊區(不是戰場) 作為常用設置 - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 在令牌上標註牌中文字 - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen 使用浮動菜單,允許右鍵單擊, 把菜單保留在屏幕上 - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings 通告設置 - + Enable notifications in taskbar 開啓任務欄通告 - + Notify in the taskbar for game events while you are spectating 觀看時在任務欄提示遊戲信息 - + Notify in the taskbar when users in your buddy list connect 在任務欄中提示好友連到伺服器 - + Animation settings 動畫設定 - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &橫置/重置 動畫 - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default 把用新頁開啟牌庫作為常用設置 - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings 重播設置 - + Buffer time for backwards skip via shortcut: 用速鍵啟動向後跳的緩衝時間: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10491,22 +14038,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10514,72 +14061,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10681,7 +14228,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10689,19 +14236,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10747,28 +14294,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10776,43 +14323,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10828,12 +14375,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10841,17 +14388,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10859,7 +14406,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10867,52 +14414,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10920,7 +14472,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10928,22 +14480,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10951,17 +14503,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10969,177 +14526,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 你想發出那種警告? - + Redact all messages from this user in all rooms 把這用戶在所有房間留下的訊息刪除 - + &OK &確認 - + &Cancel &取消 - + Warn user for misconduct 對行為不當的用戶發出警告 - - + + Error 出現錯誤 - + User name to send a warning to can not be blank, please specify a user to warn. 接收警告的用戶名稱不能空白.請定好要接收警告的用戶名字. - + Warning to use can not be blank, please select a valid warning to send. 警告名稱不能留空. 請選擇有效的警告. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top 將選擇的牌組送到最頂 - + Move selected set up 將選擇的牌組往上一格 - + Move selected set down 將選擇的牌組往下一格 - + Move selected set to the bottom 將選擇的牌組送到最底 - - Search by set name, code, or type - 用排組名稱,代碼,或類型進行搜尋 - - - + Default order 常用次序 - + Restore original art priority order 恢復常用藝術先後次序 - + Enable all sets 啟用所有牌組 - + Disable all sets 禁用所有卡组 - + Enable selected set(s) 啟用選擇的牌組(眾)  - + Disable selected set(s) 停用選擇的牌組(眾) - + Deck Editor 牌庫編輯 - + Use CTRL+A to select all sets in the view. 請按 CTRL + A, 以此選擇所有看到的牌組 - + Only cards in enabled sets will appear in the card list of the deck editor. 只有已經被啟用的卡牌組中的卡牌,才會在牌庫編輯中的卡牌清單出現 - + Image priority is decided in the following order: 卡牌圖片先後次序由以下次序決定: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki 自訂檔案夾(%`1)優先, 此對話窗中已被啟用的牌組為後(由頂到底) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 卡牌藝術 - + + Search by set name, code, type, or release date + + + + How to use custom card art 如何使用自定卡牌圖片 - + Hints 提示 - + Note 筆記 - + Sorting by column allows you to find a set while not changing set priority. 按列排序允許您在不更改系列優先級別的情況下查找牌組。 - + To enable ordering again, click the column header until this message disappears. 要再次啓用排序,請單擊列標題,直到此消息消失。 - + Use the current sorting as the set priority instead 用現在排序代替牌組先後 - + Sorts the set priority using the same column 用同一列分牌組先後 - + Manage sets 管理牌組 @@ -11147,93 +14729,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped 未分組 - + Group by Type 按類型分組 - + Group by Mana Value 按法力數值分組 - + Group by Color 按顏色排序 - + Unsorted 未排序 - + Sort by Name 按名稱排次序 - + Sort by Type 按類型排序 - + Sort by Mana Cost 按法力費用排序 - + Sort by Colors 按顏色(眾) 排序 - + Sort by P/T 按力量/防禦力排序 - + Sort by Set 按排組排序 - + shuffle when closing 關閉界面時洗牌 - + pile view 棟疊觀看 - - i18n - - - English - - - main - + Connect on startup 啟動時自動連接 - + Debug to file 偵錯紀錄寫進檔案 @@ -12017,6 +15591,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard 墳場 + + + Report Queue + + + + + Moderation + + diff --git a/oracle/translations/oracle_yue.ts b/oracle/translations/oracle_yue.ts index d13848c34..1be3677fe 100644 --- a/oracle/translations/oracle_yue.ts +++ b/oracle/translations/oracle_yue.ts @@ -2,22 +2,22 @@ IntroPage - + Introduction 介绍 - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. 此嚮導將會導入Cockatrice中用到的系列,卡牌和衍生物列表。 - + Interface language: 介面語言: - + Version: 版本: @@ -25,192 +25,206 @@ LoadSetsPage - + Source selection 選擇來源 - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. 請選擇合適的牌組和卡片清單來源。您可以輸入下載URL或使用電腦中已有的檔案。 - + Download URL: 下载URL: - + Local file: 本地檔案: - + Restore default URL 恢復常用URL - + Choose file... 選擇檔案... - + Load sets file 载入牌组檔案 - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error 出現錯誤 - + The provided URL is not valid. 所提供的url無效. - - Downloading (0MB) - 下載中 (0MB) - - - + Please choose a file. 請選擇檔案 - + Cannot open file '%1'. 不能開啟檔案 '%1 - - Downloading (%1MB) - 下載中 (%1MB) - - - + Network error: %1. 網路出錯: %1. - + Parsing file 分析檔案中 - + Xz extraction failed. xz解壓縮失敗. - + Sorry, this version of Oracle does not support xz compressed files. 很抱歉,現前Oracle版本不支援xc壓縮檔案 - + Failed to open Zip archive: %1. 未能開啟壓縮文件: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. 解壓縮失敗:這壓縮文件擁有超過一個檔案. - + Zip extraction failed: %1. 解壓縮失敗: %1. - + Sorry, this version of Oracle does not support zipped files. 很抱歉,現前Oracle版本不支援壓縮檔案. - + Failed to interpret downloaded data. 不能分析下載資料. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? 你是否想另再下載未經壓縮的檔案? - + + The file was retrieved successfully, but it does not contain any sets data. 雖然檔案成功取回, 但是檔案並未含有任何牌組資料. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database 儲存預覽資料庫 - + XML; spoiler database (*.xml) XML ; 預覽資料庫(*.XML) - + spoiler - + Spoilers import 導入預覽卡牌 - + Please specify a compatible source for spoiler data. 請指定預覽資料合用來源. - + Download URL: 下载URL: - + Local file: - + Restore default URL 恢復常用URL - + Choose file... - + The spoiler database will be saved at the following location: 預覽資料庫將會儲藏於以下位置 - + Save to a custom path (not recommended) 儲存到自訂路徑(不建議) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database 儲存衍生物資料庫 - + XML; token database (*.xml) XML; 衍生物資料庫 (*.xml) - + tokens - + Tokens import 導入衍生物 - + Please specify a compatible source for token data. 請指定衍生物資料合用來源. - + Download URL: 下载URL: - + Local file: - + Restore default URL 恢復常用URL - + Choose file... - + The token database will be saved at the following location: 衍生物資料庫將會儲藏於以下位置: - + Save to a custom path (not recommended) 儲存到自訂路徑(不建議) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens 包含衍生物的虚拟牌組 @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Oracle導入器 @@ -292,22 +306,22 @@ OutroPage - + Finished 完成 - + The wizard has finished. 嚮導程序工作完成 - + You can now start using Cockatrice with the newly updated cards. Cockatrice已載入更新了卡牌, 現在已經可以使用. - + If the card databases don't reload automatically, restart the Cockatrice client. 如果卡牌資料庫尚未能自動更新,請把cockatrice用戶端重新開啟. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error 出現錯誤 - + + No set has been imported. 沒有牌組被導入。 - + Sets imported 已導入牌組 - - A cockatrice database file of %1 MB has been downloaded. - 有%1MB大小的Cockatrice資料庫檔案已被下載 + + A Cockatrice card database file of %1 MB has been downloaded. + - + The following sets have been found: 已找到以下牌组: - + Press "Save" to store the imported cards in the Cockatrice database. 要將導入卡牌保存於Cockatrice資料庫內請按"儲藏". - + The card database will be saved at the following location: 卡牌資料庫將被儲藏於以下位置 - + Save to a custom path (not recommended) 儲存到自訂路徑(不建議) - + &Save &儲存 - + + Import finished: %1 cards. 導入過程完成:成功導入%1張卡牌. - + + %1: %2 cards imported %1:成功導入%2張卡牌. - + Save card database 儲存卡牌資料庫 - + XML; card database (*.xml) XML; 卡牌資料庫 (*.xml) - + The file could not be saved to %1 檔案未能儲藏於%1。 @@ -414,8 +431,9 @@ - Downloading (0MB) - 下載中(0MB) + Downloading (0 MB) + Downloading (0MB) + @@ -587,20 +605,20 @@ i18n - + English - + 粵語 (Cantonese) main - + Only run in spoiler mode 限於在預覽模式運行 - + Run in no-confirm background mode From 12587e2da947db5503f8bdce4a7dc32db0dd95f8 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:43:14 +0200 Subject: [PATCH 070/247] Updates for project Cockatrice and language ru (#7561) * Translate oracle_en@source.ts in ru [Manual Sync] 71% of minimum 5% translated source file: 'oracle_en@source.ts' on 'ru'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format * Translate cockatrice_en@source.ts in ru [Manual Sync] 48% of minimum 5% translated source file: 'cockatrice_en@source.ts' on 'ru'. Sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format --------- Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- cockatrice/translations/cockatrice_ru.ts | 7928 ++++++++++++++++------ oracle/translations/oracle_ru.ts | 210 +- 2 files changed, 5873 insertions(+), 2265 deletions(-) diff --git a/cockatrice/translations/cockatrice_ru.ts b/cockatrice/translations/cockatrice_ru.ts index 26d95fe1c..9bdedd7b9 100644 --- a/cockatrice/translations/cockatrice_ru.ts +++ b/cockatrice/translations/cockatrice_ru.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Установить жетоны... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Установить жетоны - + New value for counter '%1': Новое значение жетонов '%1': @@ -36,66 +36,115 @@ AbstractTabDeckEditor - + Open in new tab Открыть в новой вкладке - + Are you sure? Вы уверены? - + The decklist has been modified. Do you want to save the changes? Деклист был изменен. Вы хотите сохранить изменения? - - - - - - + + + + + + Error Ошибка - + Could not open deck at %1 Не удалось открыть колоду по пути «%1» - + Could not save remote deck Не удалось сохранить сетевую колоду - - + + The deck could not be saved. Please check that the directory is writable and try again. Колода не может быть сохранена. Проверьте что директория доступна для записи и попробуйте снова. - + Save deck Сохранить колоду - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. Не удалось сохранить колоду. - + There are no cards in your deck to be exported В колоде нет карт для экспорта + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes Список изменений - + Admin Notes for %1 Примечания администратора для %1 @@ -138,218 +187,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds секунд - + Error Ошибка - + Could not create themes directory at '%1'. Не удалость создать папку для тем в '%1'. - + Theme settings Настройки темы - + Current theme: Текущая тема: - + Open themes folder Открыть папку с темами - + Home tab background source: - Путь до обоев на домашней вкладке: + Путь к обоям вкладки навигации: - + Home tab background shuffle frequency: Частота смены обоев: - + Disabled Отключено - + Menu settings Настройки меню - + Show keyboard shortcuts in right-click menus Отображать сочетания клавиш в контекстном меню - + Show game filter toolbar above list in room tab - + Card rendering Отрисовка карт - + Display card names on cards having a picture Отображать название карты поверх изображения - + Auto-Rotate cards with sideways layout Автоматически поворачивать карты альбомной ориентации - + Override all card art with personal set preference (Pre-ProviderID change behavior) Перезаписать все арты в соответствии с выбранными изданиями (Pre-ProviderID меняет это поведение) - + Light - + Светлая - + Dark - + Тёмная - + + System + Системная + + + + Show all playmats - + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Настройки вкладки навигации - + Display card name of background in bottom right + Отображать название карты в правом нижнем углу + + + + Dim the home tab background - + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Издания карт - + Bump sets that the deck contains cards from to the top in the printing selector Перемещать в начало списка выбора издания те выпуски, из которых в колоде есть карты. - + Scale cards on mouse over Увеличивать карты при наведении мыши - + Use rounded card corners Использовать скругленные углы карт - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand Минимальный процент наложения карт в стеке и вертикальной руке - + Maximum initial height for card view window: Максимальная начальная высота для окна просмотра карты - - + + rows строки - + Maximum expanded height for card view window: Макс. высота окна для просмотра карт - + Card counters Жетоны на картах - + Counter %1 Жетон %1 - + Hand layout Раскладка руки - + Display hand horizontally (wastes space) Отображать руку горизонтально (требует места) - + Enable left justification Выравнивание по левому краю - + Table grid layout Сетка - + Invert vertical coordinate Инвертировать вертикальные координаты - + Minimum player count for multi-column layout: Минимальное количество игроков для столбчатого расположения: - + Maximum font size for information displayed on cards: Максимальный размер шрифта для текста карт: @@ -357,14 +492,14 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor - + Открыть колоду в редакторе @@ -388,112 +523,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Забанить по &Имени игрока - + ban &IP address Забанить по &адресу IP - + ban client I&D Забанить по I&D клиента - + Ban type Тип бана - + &permanent ban &постоянный бан - + &temporary ban &временный бан - + &Days: &Дни - + &Hours: &Часы - + &Minutes: &Минуты - + Duration of the ban Длительность бана - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Пожалуйста, назовите причину бана. Эта информация сохраняется для модераторов и не будет видна забаненному игроку - + Please enter the reason for the ban that will be visible to the banned person. Пожалуйста, назовите причину бана, которая будет видна забаненному игроку. - + Redact all messages from this user in all rooms Отредактировать все сообщения от этого пользователя во всех комнатах - + &OK &ОК - + &Cancel &Отмена - + Ban user from server Забанить игрока на этом сервере - - - - + + + + Error Ошибка - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Необходимо выбрать тип бана: по имени, по адресу IP, по ID клиента или комбинацию этих трех типов. - + You must have a value in the name ban when selecting the name ban checkbox. Необходимо ввести имя игрока при выборе опции бана по имени. - + You must have a value in the ip ban when selecting the ip ban checkbox. Необходимо ввести адрес IP при выборе опции бана по IP. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Необходимо ввести ID клиента при выборе опции бана по ID. @@ -501,22 +636,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta Бета - + No reply received from the release update server. Нет ответа от сервера обновления версий. - + Invalid reply received from the release update server. Неправильный ответ от сервера обновления версий. - + No reply received from the file update server. Нет ответа от сервера обновления файлов. @@ -524,7 +659,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -532,22 +680,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -555,65 +703,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name Название - + Sets Издания - + Mana cost Мана-стоимость - + Card type Тип - + P/T Сила/Выносливость: - + Color(s) Цвет(а) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Добавить в колоду - + Add to Sideboard - + Добавить в сайд - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -682,7 +974,7 @@ This is only saved for moderators and cannot be seen by the banned person. Mana Value - Количество маны + Мановая ценность @@ -702,7 +994,7 @@ This is only saved for moderators and cannot be seen by the banned person. Loyalty - Преданность + Верность @@ -712,7 +1004,7 @@ This is only saved for moderators and cannot be seen by the banned person. Main Type - Тип + Основной тип @@ -723,22 +1015,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image Изображение - + Description Описание - + Both Оба - + View transformation Обратная сторона @@ -746,22 +1038,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards Посмотреть связанные карты - + Add card to deck Добавить карту в колоду - + Mainboard Колода - + Sideboard Сайд @@ -769,27 +1061,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: Название: - + Set: - + Издание: - + Collector Number: - + Related cards: Связанные карты: - + Unknown card: Неизвестная карта: @@ -797,129 +1089,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... Показать... - + &All players Всем игрокам - + View related cards Посмотреть связанные карты - + Token: Фишка: - + All tokens Все фишки: - + &Select All Выбрать все - + S&elect Row Выбрать строку - + S&elect Column Выбрать колонку - + &Play Разыграть - + &Hide Спрятать - + Play &Face Down Разыграть лицом вниз - + &Tap / Untap Turn sideways or back again Повернуть / Развернуть - + Skip &untapping - + Пропустить разворот - + T&urn Over Turn face up/face down Перевернуть - + &Peek at card face Посмотреть лицевую сторону - + &Clone Клонировать - + Attac&h to card... Прикрепить к карте... - + Unattac&h Открепить - + &Draw arrow... Нарисовать стрелку - + &Set annotation... Добавить заметку - + Reduce life by power - + Ca&rd counters Жетоны на карте - + &Add counter (%1) &Добавить жетон (%1) - + &Remove counter (%1) &Убрать жетон (%1) - + &Set counters (%1)... &Установить жетоны (%1)... @@ -934,13 +1226,13 @@ This is only saved for moderators and cannot be seen by the banned person. Filesystem - + Файловая система CardSizeWidget - + Card Size Размер карты @@ -1080,6 +1372,24 @@ This is only saved for moderators and cannot be seen by the banned person.кастомной зоны '%2' игрока '%1' + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1109,280 +1419,382 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 - + Scheduled server shutdown Плановый перерыв в работе сервера - + Failed Login Не удалось подключиться - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error Ошибка - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Активация учетной записи - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Кажется, Вы пытаетесь зарегистрировать новую учетную запись, но этот адрес электронной почты уже используется. Этот сервер ограничивает количество учетных записей, которые можно зарегистрировать на один и тот же адрес электронной почты. Пожалуйста, свяжитесь с оператором сервера для поддержки или для получения сведений об учётной записи. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1390,113 +1802,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Сбросить пароль - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1546,7 +1958,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1562,17 +1974,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Искать по названию карты (или по поисковому запросу) - + Add card to &maindeck Добавить карту в колоду - + Add card to &sideboard Добавить карту в сайд @@ -1580,97 +1992,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card Обложка колоды - + Main Type - Тип + Основной тип - + Mana Cost Мана-стоимость - + Colors Цвет - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing Выбрать издание - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck Колода - + Deck &name: Название колоды: - + Banner Card/Tags Visibility Settings Настройки отоборажение обложки и тегов - + Show banner card selection menu Показать меню выбора обложки - + Show tags selection menu Показать меню выбора тегов - + &Comments: Комментарий: - + Group by: Группировать по: - + Format: - + Hash: Хэш-сумма - + &Increment number Увеличить количество - + &Decrement number Уменьшить количество - + &Remove row Удалить строку - + Swap card to/from sideboard Переместить карту в/из сайда @@ -1678,17 +2151,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters Фильтры - + &Clear all filters Убрать все фильтры - + Delete selected Удалить выбранное @@ -1696,114 +2169,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor Редактор колоды - + &New deck Новая колода - + &Load deck... Загрузить колоду - + Load recent deck... Загрузить недавнюю колоду - + Clear Очистить - + &Save deck Сохранить колоду - + Save deck &as... Сохранить колоду как... - + + Share deck... + + + + Load deck from cl&ipboard... Загрузить колоду из буфера обмена - + Edit deck in clipboard Редактировать колоду из буфера обмена - - + + Annotated Аннотировано - - + + Not Annotated Не аннотировано - + Save deck to clipboard Сохранить колоду в буфер обмена - + Annotated (No set info) Аннотировано (нет информации об издании) - + Not Annotated (No set info) Не аннотировано (нет информации об издании) - + &Print deck... Распечатать колоду... - + Load deck from online service... Загрузить колоду из онлайн-сервиса - + &Send deck to online service Сохранить колоду в онлайн-сервис - + Create decklist (decklist.org) Создать деклист (decklist.org) - + Create decklist (decklist.xyz) Создать деклист (decklist.xyz) - + Analyze deck (deckstats.net) Анализировать колоду (deckstats.net) - + Analyze deck (tappedout.net) Анализировать колоду (tappedout.net) - + &Close Закрыть @@ -1811,7 +2289,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector Выбрать издание @@ -1819,110 +2297,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers Обновить спойлеры - + Success Успешно - + Download URLs have been reset. Адреса URL загрузки были сброшены. - + Add URL Добавить URL - - + + URL: URL: - - + + Edit URL Изменить URL: - + Add New URL Добавить новый URL - + Remove URL Удалить URL - + Updating... Обновление... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path Выбрать путь - + URL Download Priority Приоритет загрузки URL - + Spoilers Спойлеры - + Download Spoilers Automatically Скачивать спойлеры автоматически - + Spoiler Location: Расположение спойлеров: - + Last Change Последние изменения - + Spoilers download automatically on launch Спойлеры скачиваются автоматически при запуске - + Press the button to manually update without relaunching Нажмите, чтобы обновить вручную без перезапуска - + Do not close settings until manual update is complete Не закрывать настройки до завершения ручного обновления - + Download card pictures on the fly Синхронная загрузка изображений карт - + How to add a custom URL Как добавить пользовательский URL - + Reset Download URLs Обнулить загруженные URL @@ -1930,32 +2450,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1963,27 +2483,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count Количество - + Set Издание - + Number Номер - + Provider ID Provider ID - + Card Название @@ -2004,17 +2524,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match Режим: полное совпадение - + Mode: Includes Режим: частичное совпадение - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) Фильтра совпадения цветов (И/ИЛИ/НЕ фильтр) @@ -2022,7 +2547,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... Редактировать теги ... @@ -2030,62 +2555,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager Управление тегами колоды - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: Редактируйте теги в колоде. Выберите нужные теги или добавьте новый: - + Add a new tag (e.g., Aggro️) Новый тег (например, Aggro) - + Add Tag Добавить тег - + Filter tags... Фильтр тегов... - + OK ОК - + Edit default tags Редактировать теги по умолчанию - + Cancel Отменить - + Invalid Input Неправильный ввод - + Tag name cannot be empty! Название тега не может быть пустым! - + Duplicate Tag Копировать тег - + This tag already exists. Этот тег уже существует @@ -2093,94 +2618,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card Обложка - + Open in deck editor Открыть в редакторе колоды - + + Share deck... + + + + Edit Tags Редактировать теги - + Rename Deck Переименовать колоду - + Save Deck to Clipboard Сохранить колоду в буфер обмена - + Annotated Аннотировано - + Annotated (No set info) Аннотировано (нет информации об издании) - + Not Annotated Не аннотировано - + Not Annotated (No set info) Не аннотировано (нет информации об издании) - + Rename File Переименовать файл - + Delete File Удалить файл - + Set Banner Card Установить обложку колоды - - + + New name: Новое название: - - + + Rename deck + + + + + Rename file + + + + + Error Ошибка - + Rename failed Не удалось переименовать - + Delete file Удалить файл - + Are you sure you want to delete the selected file? Уверены, что хотите удалить выбранный файл? - + Delete failed Не удалось удалить @@ -2188,57 +2728,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added Добавлено - + Removed Удалено @@ -2260,97 +2845,125 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... Загрузить колоду... - + Load remote deck... Загрузить колоду удаленно... - + Load from clipboard... Загрузить из буфера обмена... - + Load from website... Загрузить из интернета... - + Unload deck Выгрузить колоду - + Ready to start &Готов - + Force start Принудительный старт - + Sideboard unlocked Сайдборд разблокирован - + Sideboard locked Сайдборд заблокирован - - + + Error Ошибка - + The selected file could not be loaded. Выбранный файл не может быть загружен - + Deck is greater than maximum file size. Колода больше максимально возможного размера файла - + Are you sure you want to force start? This will kick all non-ready players from the game. Уверены что хотите принудительно начать? Это кикнет всех неготовых игроков из игры - + Cockatrice Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion Изменить формат колоды - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? Вы пытаетесь присвоивить тег колоде в формате .txt Теги могут быть добавлены только к колоде в формате .cod -Хотите конвертировать колоду в фомат .cod? +Хотите конвертировать колоду в формат .cod? - + Remember and automatically apply choice in the future Запомнить выбор @@ -2363,143 +2976,143 @@ This will kick all non-ready players from the game. Загрузка... - + Known Hosts Известные Хосты - + Delete the currently selected saved server Удалить текущий выбранный сервер - + Refresh the server list with known public servers Обновить список известных публичных серверов - + New Host Новый хост - + Name: Название: - + &Host: &Хост: - + &Port: &Порт: - + Player &name: &Имя пользователя: - + P&assword: П&ароль: - + &Save password &Сохранить пароль - + A&uto connect Присоединяться автоматически - + Automatically connect to the most recent login when Cockatrice opens Автоматически подключится с последним логином при открытии Кокатрис - + If you have any trouble connecting or registering then contact the server staff for help! Если у вас появилась проблема при присоединении или регистрации - свяжитесь с сотрудниками для помощи! - - + + Webpage Веб-страница - + Reset Password Обнулить пароль - + Forgot password? Забыли пароль? - + &Connect &Подключиться - + Server Сервер - + Login Логин - + Server Contact Соединение с сервером - + Connect to Server Подключиться к серверу - + Server URL URL сервера - + Communication Port Порт связи - + Unique Server Name Уникальное имя сервера - + Connection Warning Предупреждение при соединении - + You need to name your new connection profile. Назовите ваш профиль для соединения. - + Connect Warning Ошибка при соединении - + The player name can't be empty. Необходимо указать имя пользователя. @@ -2507,122 +3120,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings &Запомнить настройки - + &Description: &Описание: - + P&layers: &Игроки: - + General Основные - + Game type Формат игры - + &Password: &Пароль: - + Only &buddies can join Только для &друзей - + Only &registered users can join Только для &зарегистрированных пользователей - + Joining restrictions Ограничения - + &Spectators can watch &Зрители могут наблюдать за игрой - + Spectators &need a password to watch Зрителям &необходим пароль для просмотра игры - + Spectators can &chat Позволить зрителям &комментировать - + Spectators can see &hands Зрители могут видеть &руки игроков - + Create game as spectator Создать игру как зритель - + Spectators Зрители - + Starting life total: Начальное количество жизней: - + Open decklists in lobby Открыть деклист в лобби - + Create game as judge - + Создать игру как судья - + Game setup options Настройки игры - + &Clear &Очистить - + Create game Создать игру - + Game information Информация об игре - + Error Ошибка - + Server error. Ошибка сервера. @@ -2630,97 +3243,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Название: - + Token Фишка - + C&olor: &Цвет: - + white белый - + blue синий - + black черный - + red красный - + green зеленый - + multicolor многоцветный - + colorless бесцветный - + &P/T: &Сила/Выносливость: - + &Annotation: &Примечание: - + &Destroy token when it leaves the table &Уничтожить фишку, когда она покинет поле битвы - + Create face-down (Only hides name) Создать лицом вниз (скроет только название) - + Token data Описание фишки - + Show &all tokens Показать &все фишки - + Show tokens from this &deck Показать фишки из указанной &колоды - + Choose token from list Выбрать фишку из списка - + Create token Создать фишку @@ -2728,53 +3341,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags Редактировать теги - + Add Добавить - + Confirm Подтвердить - + Cancel Отменить - + Enter a tag and press Enter Введите тег и нажмите Enter - - + + ✖ ✖ - + Invalid Input Неправильный ввод - + Tag name cannot be empty! Название тега не может быть пустым! - + Duplicate Tag Копировать тег - + This tag already exists. Этот тег уже существует @@ -2783,7 +3396,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. Изображение не выбрано. @@ -2795,27 +3408,32 @@ To remove your current avatar, confirm without choosing a new image. Чтобы удалить текущий аватар, нажмите "Сменить аватар", оставив поле выбора изображения пустым. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... Поиск... - + Change avatar Сменить аватар - + Open Image Открыть изображение - + Image Files (*.png *.jpg *.bmp) Файлы изображений (*.png *.jpg *.bmp) - + Invalid image chosen. Выбрано не подходящее изображение. @@ -2841,38 +3459,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: Старый пароль: - + New password: Новый пароль: - + Confirm new password: Подтверждение пароля: - + Change password Сменить пароль - - + + Error Ошибка - + Your password is too short. Пароль слишком короткий - + The new passwords don't match. Введенные пароли не совпадают. @@ -2880,93 +3498,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: &Название - + C&olor: &Цвет - + white белый - + blue синий - + black чёрный - + red красный - + green зелёный - + multicolor мультицветный - + colorless бесцветный - + &P/T: &Сила/Выносливость: - + &Annotation: &Примечание: - + Token data Описание фишки - - + + Add token Добавить фишку - + Remove token Удалить фишку - + Edit custom tokens Редактировать пользовательские фишки - + Please enter the name of the token: Введите имя фишки: - + Error Ошибка - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Выбранное имя конфликтует с существующей картой или токеном. @@ -3045,8 +3663,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started - Скрыть уже начавшиеся игры + Hide started games + Hide games that have started + @@ -3081,8 +3700,9 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: - Имя &создателя + &Host name: + &Creator name: + @@ -3148,52 +3768,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning Предупреждение при проверке сброса пароля - + A problem has occurred. Please try to request a new password again. Возникла проблема. Попробуй запросить новый пароль еще раз - + Enter the information of the server and the account you'd like to request a new password for. Введите информации о сервере и учетной записи, для которой нужен новый пароль - + &Host: &Хост: - + &Port: &Порт: - + Player &name: &Имя игрока: - + Email: Электронная почта: - + Reset Password Challenge Сбросить пароль - + Reset Password Challenge Error Предупреждение при проверке сброса пароля - + The email address can't be empty. Необходимо указать ваш e-mail @@ -3201,37 +3821,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. Введите информации о сервере и учетной записи, для которой нужен новый пароль - + &Host: &Хост: - + &Port: &Порт: - + Player &name: &Имя игрока: - + Reset Password Request Забыл пароль - + Reset Password Error Ошибка сброса пароля - + The player name can't be empty. Необходимо указать имя игрока. @@ -3239,94 +3859,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning Сбросить предупреждение о пароле - + A problem has occurred. Please try to request a new password again. Возникла проблема. Попробуй запросить новый пароль еще раз - + Enter the received token and the new password in order to set your new password. Введите полученный токен и новый пароль - + &Host: &Хост: - + &Port: &Порт: - + Player &name: &Имя игрока: - + Token: Жетон: - - + + New Password: Новый пароль: - + Reset Password Сбросить пароль - + The player name can't be empty. Необходимо указать имя игрока. - - - - + + + + Reset Password Error Ошибка сброса пароля - + The token can't be empty. Необходимо указать код активации. - + The new password can't be empty. Необходимо ввести новый пароль. - + Error Ошибка - + Your password is too short. Пароль слишком короткий - + The passwords do not match. Введенные пароли не совпадают. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + Пригласить в игру + + + + Search users... + + + + + Invite + Пригласить + + + + Cancel + + + DlgLoadDeck - + Load Deck Загрузить колоду @@ -3418,166 +4071,567 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: Игроки - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + Настройки локальной игры + + + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server DlgMoveTopCardsUntil - + Card name (or search expressions): Название карты (или поисковый запрос) - + Number of hits: Количество: - + Auto play hits Автоматически разыгрывать найденные карты - + Put top cards on stack until... Поместить вернюю карту в стек, пока... - + No cards matching the search expression exists in the card database. Proceed anyways? В базе данных карт ничего не найдено. Все равно продолжить? - + Cockatrice Cockatrice - + Invalid filter Неправильный фильтр + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. Введите информацию о себе и о сервере, на котором хотите зарегистрироваться. На электронную почту будет отправлено письмо активации учетной записи. - + &Host: &Хост - + &Port: &Порт: - + Player &name: &Имя пользователя: - + P&assword: Пароль: - + Password (again): Подтверждение пароля: - + Email: Адрес email: - + Email (again): Подтверждение email: - + Country: Страна: - + Undefined Не определено - + Real name: Настоящее имя: - + Register to server Зарегистрироваться на сервере - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning Предкпреждение - + Your password is too short. Пароль слишком короткий - + Your passwords do not match, please try again. Введенные пароли не совпадают, повторите снова. - + Your email addresses do not match, please try again. Введенные адреса email не совпадают, повторите снова. - + The player name can't be empty. Необходимо указать имя пользователя. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3643,40 +4697,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Неизвестная ошибка при загрузке базы карт. - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - База карт содержит ошибки. -Cockatrice не может корректно работать с испорченной базой карт. -Возможно, Вам необходимо обновить базу карт с помощью Oracle. -Вы хотите сменить расположение файла базы карт? - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - Версия вашей базы карт сильно устарела. -Это может привести к проблемам при загрузке информации о картах или изображений. -Возможно, Вам необходимо обновить базу карт с помощью Oracle. -Вы хотите сменить расположение файла базы карт? - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3689,7 +4715,7 @@ Would you like to change your database location setting? Вы хотите изменить путь до локальной базы карт? - + File Error loading your card database. Would you like to change your database location setting? @@ -3697,7 +4723,7 @@ Would you like to change your database location setting? Вы хотите сменить расположение файла базы карт? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3705,7 +4731,7 @@ Would you like to change your database location setting? Вы хотите сменить расположение файла базы карт? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3718,106 +4744,237 @@ Would you like to change your database location setting? Вы хотите изменить путь до локальной базы карт? - - - + + + Error Ошибка - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Ваши колоды отсутствуют в указанной папке. Вернуться и задать правильный путь? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Изображения карт не найдены. Вернуться и задать правильный путь? - + Settings Настройки - + + Search settings... + + + + + OK + + + + General Основные - + Appearance Вид - + User Interface Интерфейс - + Storage - + Card Sources Источник карт - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat Чат - + Sound Звук - + Shortcuts Горячие клавиши + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check База карт: - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - Прошло более %2 после последнего обновления базы карт. + Прошло более %2 дней после последнего обновления базы карт. Выберите способ запуска приложения для обновления базы карт. Вы сможете изменить выбранный способ позднее во кладке настроке "Основные". - + Run in foreground Запустить на переднем плане - + Run in background Запустить в фоне - + Run in background and always from now on - Запустить в фоне сейчас и всегда а будущем + Запустить в фоне сейчас и всегда в будущем - + Don't prompt again and don't run Не спрашивать снова и не запускать - + Don't run this time Не запускать в этот раз @@ -3825,58 +4982,58 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next Следующий - + Previous Предыдущий - + Tip of the Day - Совет дня + Подсказка дня DlgUpdate - + Current release channel Канал актуального обновления - + Reinstall Переустановить - + Cancel Download Отменить загрузку - + Open Download Page Открыть страницу загрузки - + Check for Client Updates Проверить обновления клиента - - - - + + + + Error Ошибка - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. В Cockatrice нет поддержки SSL и вы не можете обновиться автоматически! @@ -3884,125 +5041,135 @@ Please visit the download page to update manually. Пожалуйста, посетите страницу загрузки для получения обновления. - + Downloading update: %1 Скачивание обновлений: %1 - + Checking for updates... Проверка обновлений... - + Finished checking for updates Закончена проверка обновлений - + No Update Available Нет доступных обновлений - + Cockatrice is up to date! Установлена последняя версия Cockatrice! - + You are already running the latest version available in the chosen release channel. Уже установлена последняя версия, доступная в выбранной ветке обновлений. - + Current version Текущая версия - + Selected release channel Выбранная ветка обновлений - - + + Update Available Обновление доступно - - + + A new version of Cockatrice is available! Доступна новая версия Cockatrice! - - + + New version Новая версия - - + + Released Выпущена - - + + Changelog Список изменений - + Do you want to update now? Вы хотите обновить сейчас? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Автообновление не удалось - подходящая версия не найдена. Скачайте новую версию вручную. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Проверьте <a href="%1">страницу релиза</a> в нашем репозитории на Github и скачайте сборку для своей системы. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error Ошибка при оновлении - + An error occurred while checking for updates: В процессе проверки обновлений произошла ошибка: - + An error occurred while downloading an update: В процессе загрузки обновления произошла ошибка: - + Installing... Установка... - + Cockatrice is unable to open the installer. Невозможно открыть установщик. - + Try to update manually by closing Cockatrice and running the installer. Попробуйте обновить вручную, закрыв Cockatrice и запустив инсталлятор. - + Download location @@ -4010,17 +5177,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing Очистить лог при закрытии - + Copy to clipboard Скопировать в буфер обмена - + Debug Log Лог @@ -4162,7 +5329,7 @@ You may have to manually download the new version. In %1 decks - в %1 колоде + в %1 колодах @@ -4258,15 +5425,15 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: - Цена: + Тилт: FilterBuilder - + Type your filter here Укажите свой фильтр @@ -4274,45 +5441,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete Подтвердить удаление - + Are you sure you want to delete the filter '%1'? Вы уверены что хотите удалить фильтр '%1'? - + Delete Failed Не удалось удалить - + Failed to delete filter '%1'. Не удалось удалить фильтр '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator кикнут из игры хостом или модератором - + player left the game игрок покинул игру - + player disconnected from server игрок отсоединился от сервера - + reason unknown причина неизвестна @@ -4320,140 +5530,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Ошибка - + Please join the appropriate room first. Пожалуйста, сперва войдите в соответствующую комнату. - + Wrong password. Неверный пароль. - + Spectators are not allowed in this game. Зрители не допускаются в эту игру. - + The game is already full. Все места уже заняты! - + The game does not exist any more. Эта игра больше не существует. - + This game is only open to registered users. Доступно только для зарегистрированных. - + This game is only open to its creator's buddies. Доступно только для друзей. - + You are being ignored by the creator of this game. Вы добавлены в игнор-лист данного игрока. - + Join Game Присоединиться - + Spectate Game Наблюдать - + Game Information Информация об игре - - Join Game as Judge - + + Cop&y game link + Скопировать ссылку на игру - + + Join Game as Judge + Присоединиться в качестве судьи + + + Spectate Game as Judge - + + Join game Присоединиться - - Password: - Пароль: + + The game is full. Join as a spectator instead? + - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. Пожалуйста, сначала присоединитесь к соответствующей комнате. - + &Filter games &Фильтр игр - + C&lear filter О&чистить фильтр - + C&reate &Создать - + &Join &Присоединиться - + Join as judge - + J&oin as spectator Присоединиться как &зритель - + Join as judge spectator - + Games shown: %1 / %2 Показано игр: %1 / %2 - + Games Игры @@ -4494,105 +5720,105 @@ You may have to manually download the new version. GamesModel - + >1 day > 1 дня - + %1%2 hr short age in hours %1%2 час%1%2 часа%1%2 часов%1%2 часов - + new - создать + только что - + %1%2 min short age in minutes %1%2 минута%1%2 минут%1%2 минут%1%2 минут - + password пароль - + buddies only только друзья - + reg. users only только для зарегистрированных пользователей - + open decklists открыть деклист - - + + can chat зрители могут комментировать в чате - + see hands видеть руки игроков - + can see hands - могут видеть руки + могут видеть руки игроков - + not allowed не допускаются - + Room Комната - + Age Возраст - + Description Описание - - Creator - Создатель + + Host + - + Type Тип - + Restrictions Ограничения - + Players Количество игроков - + Spectators Зрители @@ -4600,208 +5826,325 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Сбросить - + All paths have been reset Все пути были сброшены - - - - - - - + + + + English + Русский (Russian) + + + + + + + + + Choose path Путь - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings Выбор языка - + Language: Язык: - - Version settings + + Card text & images language: - + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + + Version settings + Настройки версии + + + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) Путь (в портативном режиме редактирование недоступно) - + Paths Пути расположения - + How to help with translations Как помочь с переводом - + Decks directory: Колоды: - + Filters directory: - Фильтровать директории: + Фильтры: - + Replays directory: Повторы: - + Pictures directory: Изображения карт: - + Card database: База данных карт: - + Custom database directory: Пользовательские сеты: - + Token database: База данных токенов: - + Update channel Обновить канал - + Check for client updates on startup Проверять обновления при запуске - + Check for card database updates on startup Проверять обновления базы карт при запуске - + Don't check Не проверять - + Prompt for update Поиск обновлений - + Always update in the background Всегда обновлять на фоне - + Check for card database updates every Проверять обновления каждые - + days дней - + Notify if a feature supported by the server is missing in my client Предупреждать, если функции, используемые сервером, отсутствуют в этом клиенте. - + Automatically run Oracle when running a new version of Cockatrice Автоматически запускать Oracle в новой версии Cockatrice - + Show tips on startup Показывать советы при запуске клиента - + Last update check on %1 (%2 days ago) - Последнее проверка обновлений была %1 (%2 дней назад) + Последняя проверка обновлений была %1 (%2 дней назад) GraveyardMenu - + &Graveyard Кладбище - + &View graveyard Посмотреть кладбище - + &Move graveyard to... Переместить кладбище в... - + &Top of library Верх библиотеки - + &Bottom of library Низ библиотеки - + &All players Всем игрокам - + &Hand Рука - + &Exile Изгнание - + Reveal random card to... - Показать случайно карту... + Показать случайную карту... @@ -4819,7 +6162,7 @@ You may have to manually download the new version. Sort hand by... - + Сортировать руку по... @@ -4893,59 +6236,353 @@ You may have to manually download the new version. Показать случайную карту... + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck Создать новую колоду - + Browse Decks Посмотреть колоды - + Browse Card Database Посмотреть базу карт - + Browse EDHRec Посмотреть EDHRec - + Browse Archidekt - + Посмотреть Archidekt - + View Replays Посмотреть повтор - + Quit Выход - + Connecting... Подключение... - + Connect Подключиться - + Play Играть + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -4961,12 +6598,12 @@ You may have to manually download the new version. View &top cards of library... - Посмотреть топдек + Посмотреть верхнюю карту библиотеки View bottom cards of library... - Посмотреть нижнюю карту библиотеки + Посмотреть нижние карты библиотеки @@ -4981,12 +6618,12 @@ You may have to manually download the new version. Reveal &top cards to... - Показать топдек... + Показать верхние карты... &Top of library... - Топдек + Верх библиотеки @@ -5031,47 +6668,47 @@ You may have to manually download the new version. &Play top card - Сыграть топдек + Сыграть верхнюю карту Play top card &face down - Сыграть топдек лицом вниз + Сыграть верхнюю карту лицом вниз Put top card on &bottom - Положить топдек на низ библиотеки + Положить верхнюю карту в низ библиотеки Move top card to grave&yard - Положить топдек в кладбище + Положить верхнюю карту на кладбище Move top card to e&xile - Положить топдек в изгнание + Положить верхнюю карту в изгнание Move top cards to &graveyard... - Положить верхние карты в кладбище + Положить верхние карты на кладбище Move top cards to graveyard face down... - + Положить верхние карты на кладбище лицом вниз... Move top cards to &exile... - Положить верхние карты в кладбище + Положить верхние карты в изгнание Move top cards to exile face down... - + Положить верхние карты в изгнание лицом вниз... @@ -5086,12 +6723,12 @@ You may have to manually download the new version. &Draw bottom card - &Взять нижнюю карту + &Взять нижнюю карту библиотеки D&raw bottom cards... - Взять нижнюю карту библиотеки + Взять нижние карты библиотеки @@ -5106,17 +6743,17 @@ You may have to manually download the new version. Move bottom card to grave&yard - Положить нижнюю карту библиотеки в кладбище + Положить нижнюю карту библиотеки на кладбище Move bottom card to e&xile - Положить нижнюю карту библиотеки в кладбище + Положить нижнюю карту библиотеки в изгнание Move bottom cards to &graveyard... - Положить нижние карты библиотеки в кладбище + Положить нижние карты библиотеки на кладбище @@ -5136,7 +6773,7 @@ You may have to manually download the new version. Put bottom card on &top - Переместить нижнюю карту на топдек + Переместить нижнюю карту на верх библиотеки @@ -5163,411 +6800,432 @@ You may have to manually download the new version. MainWindow - - + + Player %1 Игрок %1 - + Load replay Загрузить повтор - + About Cockatrice О программе - + Version Версия - + Cockatrice Webpage Страница Cockatrice - + Project Manager: Руководитель проекта: - + Past Project Managers: Предыдущие руководители проекта: - + Developers: Разработчики: - + Our Developers Наши разработчики - + Help Develop! Помочь в разработке! - + Translators: Переводчики: - + Our Translators Наши переводчики - + Help Translate! Помочь с переводом! - + Support: Поддержать проект: - + Report an Issue Сообщить о проблеме - + Troubleshooting Устранение неполадок - + F.A.Q. ЧАВо - - + + Error Ошибка - + &Connect... Подключение... &С - + &Disconnect П&рервать подключение - + Start &local game... &Начать локальную игру... - + &Watch replay... &Смотреть повтор... - + &Full screen Полный экран &F - + &Register to server... &Регистрация на сервере... - + &Restore password... - + Восстановить пароль... - + &Settings... &Настройки - + &Exit &Выход - + A&ctions &Действия - + &Cockatrice &Cockatrice - + C&ard Database &База карт - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder Открыть папку с изображениями - + Open custom sets folder Открыть папку с изданиями - + Add custom sets/cards Добавить издания/карты - + Reload card database - + Tabs Быстрый доступ - + &Help &Справка - + &About Cockatrice О про&грамме - + &Tip of the Day - + Подсказка дня - + Check for Client Updates - + Проверить обновления клиента - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder + Открыть папку настроек + + + + Re-run Onboarding Wizard... - + Show/Hide + Показать/скрыть + + + + Sign-in cancelled - + New Version - + Новая версия - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Установка Cockatrice завершена - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + Вы уверены? + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database База карт - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" Cockatrice не может загрузить базу карт. Обновить вашу базу карт сейчас? Если Вы не уверены, или впервые запустили программу, нажмите "Да" - - + + Yes Да - - + + No Нет - + Open settings Открыть настройки - + New sets found Найдены новые издания - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + Yes, always enable - + View sets Просмотреть издания - + Welcome Добро пожаловать - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information Информация - + A card database update is already running. Обновление базы карт уже идет. - + Unable to run the card database updater: Не удалось запустить обновление базы карт: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards Загрузить издания/карты - + Selected file cannot be found. Выбранный файл не найден. - + You can only import XML databases at this time. На данный момент вы только можете предостовлять базы данных в формате XML. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Новые издания/карты успешно добавлены. Сейчас Cockatrice перезагрузит базу карт. - + Sets/cards failed to import. Не удалось импортировать издания/карты. @@ -5582,17 +7240,17 @@ Cockatrice will now reload the card database. Display type: - + Режим отображения: pie - + круговая диаграмма bar - + гистограмма @@ -5612,7 +7270,7 @@ Cockatrice will now reload the card database. Cancel - + Отменить @@ -5628,7 +7286,7 @@ Cockatrice will now reload the card database. Group By: - + Группировать по: @@ -5735,7 +7393,7 @@ Cockatrice will now reload the card database. Colors: - + Цвета: @@ -5761,7 +7419,7 @@ Cockatrice will now reload the card database. Mana Production + Devotion - + Производство маны + преданность @@ -5772,615 +7430,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play из игры - + from their graveyard из их кладбищ - + from exile из изгнания - + from their hand - из их рук + из своей руки - + the top card of %1's library верхняя карта библиотеки %1 - + the top card of their library верхнюю карту своей библиотеки - + from the top of %1's library с верха библиотеки %1 - + from the top of their library с верха своей библиотеки - + the bottom card of %1's library нижняя карта библиотеки %1 - + the bottom card of their library нижнюю карту своей библиотеки - + from the bottom of %1's library Со дна библиотеки %1 - + from the bottom of their library с низа своей библиотеки - + from %1's library из библиотеки %1 - + from their library из своей библиотеки - + from sideboard из сайдборда - + from the stack из стека - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 оставляет верхнюю карту %2 открытой. - + %1 is not revealing the top card %2 any longer. Верхняя карта %2 %1 больше не остается открытой. - + %1 can now look at top card %2 at any time. - + %1 теперь может в любой момент посмотреть верхнюю карту %2. - + %1 no longer can look at top card %2 at any time. - + %1 больше не может в любой момент посмотреть верхнюю карту %2 . - + %1 attaches %2 to %3's %4. %1 прикрепляет %2 к %4 под контролем %3. - + %1 has conceded the game. %1 сдался. - + %1 has unconceded the game. - + %1 has restored connection to the game. %1 восстановил связь с игрой. - + %1 has lost connection to the game. %1 потерял связь с игрой. - + %1 points from their %2 to themselves. %1 указывает с их %2 на себя. - + %1 points from their %2 to %3. %1 указывает с их %2 на %3. - + %1 points from %2's %3 to themselves. %1 указывает с %3 под контролем %2 на себя. - + %1 points from %2's %3 to %4. %1 указывает с %3, контролируемого %2, на %4. - + %1 points from their %2 to their %3. %1 указывает с их %2 на их %3. - + %1 points from their %2 to %3's %4. %1 указывает с их %2 на %4 под контролем %3. - + %1 points from %2's %3 to their own %4. %1 указывает с %3 под контролем %2 на %4, владельцем которого они являются. - + %1 points from %2's %3 to %4's %5. %1 указывает с %3 контролируемого %2 на %5 под контролем %4. - + %1 creates a face down token. - + %1 создает фишку лицом вниз. - + %1 creates token: %2%3. %1 создает фишку: %2%3. - + %1 has loaded a deck (%2). %1 загрузил колоду (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 загрузил колоду с %2 картами из сайдборда (%3). - + %1 destroys %2. %1 уничтожает %2. - + a card карту - + %1 gives %2 control over %3. %1 передает %2 контроль над %3. - - - %1 puts %2 into play%3 face down. - - - - - %1 puts %2 into play%3. - %1 поместил %2 на поле битвы %3. - - - - %1 puts %2%3 into their graveyard face down. - - + %1 puts %2 into play%3 face down. + %1 кладет %2 на поле битвы %3 лицом вниз. + + + + %1 puts %2 into play%3. + %1 кладет %2 на поле битвы %3. + + + + %1 puts %2%3 into their graveyard face down. + %1 кладет %2%3 на своё кладбище лицом вниз. + + + %1 puts %2%3 into their graveyard. %1 кладет %2%3 на своё кладбище. - + %1 exiles %2%3 face down. - + %1 изгоняет %2%3 лицом вниз. - + %1 exiles %2%3. %1 изгоняет %2%3. - + %1 moves %2%3 to their hand. %1 кладет %2%3 в свою руку. - + %1 puts %2%3 into their library. %1 кладет %2%3 в свою библиотеку. - + %1 puts %2%3 onto the bottom of their library. - + %1 кладет %2%3 в низ своей библиотеки. - + %1 puts %2%3 on top of their library. %1 кладет %2%3 на верх своей библиотеки. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 кладет %2%3 в свою библиотеку %4 сверху. - + %1 moves %2%3 to sideboard. %1 поместил %2%3 в сайд. - + %1 plays %2%3 face down. - + %1 разыгрывает %2%3 лицом вниз. - + %1 plays %2%3. %1 разыгрывает %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 пытается взять карту из пустой библиотеки. - + %1 draws %2 card(s). %1 берет %2 карту.%1 берет %2 карты.%1 берет %2 карт.%1 берет %2 карты. - + %1 is looking at %2. %1 просматривает %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 переворачивает %2 лицом вниз. - + %1 turns %2 face-up. - + %1 переворачивает %2 лицом вверх. - + The game has been closed. Игра закрыта. - + The game has started. Игра началась. - + You are flooding the game. Please wait a couple of seconds. - + Вы слишком быстро отправляете сообщения. Пожалуйста, подождите пару секунд. - + %1 has joined the game. %1 присоединился к игре. - + %1 is now watching the game. - %1 вошел как зритель. + %1 теперь наблюдает за игрой. - + You have been kicked out of the game. Вы были отключены от игры - - - %1 has left the game (%2). - - - %1 is not watching the game any more (%2). - + %1 has left the game (%2). + %1 покинул(а) игру (%2). - + + %1 is not watching the game any more (%2). + %1 больше не наблюдает за игрой (%2). + + + %1 is not ready to start the game any more. %1 пока не готов начать игру. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 тасует колоду и берет новую руку из %2 карты%1 тасует колоду и берет новую руку из %2 карт%1 тасует колоду и берет новую руку из %2 карт%1 тасует колоду и берет новую руку из %2 карт - + %1 shuffles their deck and draws a new hand. - + %1 тасует колоду и берет новую руку. - + You are watching a replay of game #%1. Вы просматриваете запись игры #%1. - + %1 is ready to start the game. - %1 готов начать игру. + %1 готов(а) начать игру. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 открывает %2 для %3. - + %1 reveals %2. %1 открывает %2. - + %1 randomly reveals %2%3 to %4. %1 случайным образом открывает %2%3 для %4. - + %1 randomly reveals %2%3. %1 случайным образом открывает %2%3. - + %1 peeks at face down card #%2. %1 указывает на перевернутую рубашкой вверх карту #%2. - + %1 peeks at face down card #%2: %3. %1 указывает на перевернутую рубашкой вверх карту #%2: %3. - + %1 reveals %2%3 to %4. %1 открывает %2%3 для %4. - + %1 reveals %2%3. %1 открывает %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads Орел - + Tails Решка - + %1 flipped a coin. It landed as %2. %1 подбросил монету. Выпал(а) %2. - + %1 rolls a %2 with a %3-sided die. %1 выбросил %2 на %3-гранном кубике. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + Ход игрока %1. - + %1 sets annotation of %2 to %3. %1 помечает %2 "%3". - + %1 places %2 %3%4 counter(s) on %5 (now %6). - + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). %1 устанавливает жетон %2 на %3 (%4%5). - + %1 sets %2 to not untap normally. %2 теперь не разворачивается как обычно (%1). - + %1 sets %2 to untap normally. %2 теперь разворачивается как обычно (%1). - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 заблокировал(а) свой сайдборд. - + %1 has unlocked their sideboard. %1 разблокировал(а) свой сайдборд. - + %1 taps their permanents. %1 поворачивает свои перманенты. - + %1 untaps their permanents. %1 разворачивает свои перманенты. - + %1 taps %2. %1 поворачивает %2. - + %1 untaps %2. %1 разворачивает %2. - + %1 shuffles %2. %1 размешал %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 открепляет %2. - + %1 undoes their last draw. %1 отменяет последние взятия. - + %1 undoes their last draw (%2). %1 отменяет последние взятия (%2). @@ -6388,115 +8046,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Слово1 Слово2 Слово3 - + Add New Message - + Edit Message - + Remove Message - + Add message Добавить сообщение - - + + Message: Сообщение: - + Edit message - + Chat settings Настройки чата - + Custom alert words Пользовательские предупреждения - + Enable chat mentions Включить упоминания в чате - + Enable mention completer Включить автозавершение упоминаний - + In-game message macros Макросы сообщений игрового чата - + How to use in-game message macros Как использовать внутриигровые макросы для сообщений - + Ignore chat room messages sent by unregistered users Не уведомлять о сообщениях в чате от незарегистрированных пользователей - + Ignore private messages sent by unregistered users Не уведомлять о личных сообщениях от незарегистрированных пользователей - + Ignore private messages sent by non-buddy users - + Не уведомлять о личных сообщениях от пользователей не из списка друзей - - + + Invert text color Инвертировать цвет текста - + Enable desktop notifications for private messages Включить оповещения о личных сообщениях - + Enable desktop notification for mentions Включить оповещения об упоминаниях - + Enable room message history on join Отображать историю сообщений при присоединении к комнате. - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) (Шестнадцатеричный цвет) - + Separate words with a space, alphanumeric characters only Разделять слова пробелом, только для букв и цифр @@ -6504,42 +8172,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6564,7 +8232,7 @@ Cockatrice will now reload the card database. Loyalty - + Верность @@ -6618,148 +8286,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6882,30 +8536,21 @@ Cockatrice will now reload the card database. Заключительный шаг - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - ru - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7024,14 +8669,270 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" + Игрок "%1" + + + + &Counters + &Жетоны + + + + PlaymatCollectionDialog + + + Default Playmats - - &Counters - &Жетоны + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + @@ -7054,12 +8955,12 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7067,25 +8968,65 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7116,28 +9057,28 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7153,95 +9094,103 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services Сервисы - + Hide %1 Скрыть %1 - + Hide Others Скрыть остальные - + Show All Показать все - + Preferences... Параметры... - + Quit %1 Выйти %1 - + About %1 О %1 @@ -7249,17 +9198,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) База карт Cockatrice (*.xml) - + All files (*.*) Все файлы (*.*) - + Cockatrice replays (*.cor) Записи игр Cockatrice (*.cor) @@ -7279,17 +9228,22 @@ This setting means you'll only see the default printing for each card, instead o Фишки - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Версия данной колоды в формате .cod уже существует. Перезаписать? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7300,7 +9254,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7311,100 +9265,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + Открыть колоду + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Да - + Yes to &All - + &No - + Нет - + N&o to All - + Abort - + Retry - + Попробовать снова - + Ignore - + Close - + Cancel - + Отменить - + Discard - + Help - + Apply - + Применить - + Reset - + Restore Defaults @@ -7412,121 +9376,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - + 70% RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name Название - + ID ID - + Upload time Дата загрузки + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID ID - + Name Имя - + Players Игроки - + Keep Хранение - + Time started Время начала - + Duration (sec) Длительность (сек) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7568,37 +9600,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms Комнаты - + Joi&n &Присоединиться - + Room Комната - + Description Пометка - + Permissions Разрешения - + Players Игроки - + Games Игры @@ -7606,7 +9638,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7614,32 +9646,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Нажмите клавишу/сочетание клавиш, которую Вы хотите назначить для этого действия - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7672,56 +9704,92 @@ Fine-tune individual colours in the grid afterwards. Дата релиза + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Действие: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7731,7 +9799,7 @@ Fine-tune individual colours in the grid afterwards. Action - + Действие @@ -7775,12 +9843,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7788,27 +9856,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Включить &звуки - + Current sounds theme: Звуковая тема: - + Test system sound engine Протестировать системный звук - + Sound settings Настройки звука - + Master volume Громкости @@ -7816,48 +9884,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection Нет подключения к Интернету - + Error - + Ошибка - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7865,157 +9933,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default По умолчанию - + No reply received from the release update server. Сервер обновлений версии не отвечает. - + Invalid reply received from the release update server. Ошибка отклика сервера обновлений версии. - + No reply received from the tag update server. Сервер обновлений меток не отвечает. - + Invalid reply received from the tag update server. Ошибка отклика сервера обновлений меток. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + Метод кэширования: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Метод кэширования для загрузчика изображений карт - + Network Cache - + Filesystem - + Файловая система - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8023,17 +10104,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Добавить в список друзей - + Add to Ignore List + Добавить в игнор-лист + + + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. - + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8118,7 +10215,7 @@ Please check your shortcut settings! Error - + Ошибка @@ -8154,166 +10251,166 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats Archidekt: - + Archidekt: TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8326,52 +10423,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info Информация о карте - + Deck Колода - + Filters Фильтры - + &View Вид - + Card Database - + Printing - + Visible Видимый - + Floating Плавающий - + Reset layout Сбросить слой - + Deck: %1 Колода: %1 @@ -8379,49 +10476,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8429,22 +10526,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8452,139 +10549,237 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Локальная файловая система - + Server deck storage Хранилище колод на сервере - - + + Open in deck editor Открыть колоду в редакторе - + Rename deck or folder - + Upload deck Загрузить колоду в хранилище - + Download deck Загрузить колоду из хранилища - - - - + + + + New folder Новая папка - - + + Delete Удалить - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Ошибка - + Rename failed - - + + + Invalid deck file - + Enter deck name Введите название колоды - + This decklist does not have a name. Please enter a name: У этой колоды нет названия. Пожалуйста, введите имя: - + Unnamed deck Безымянная колода - + Failed to upload deck to server - + Delete local file Удалить файл - + Are you sure you want to delete the selected files? - + Вы уверены, что хотите удалить выбранные файлы? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + %n deck(s) + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + Name of new folder: Название новой папки: - + Deck Storage @@ -8592,250 +10787,517 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec EDHREC: - + EDHREC: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Найти карту... - + Search - + Искать EDHRec: - + EDHRec: TabGame - - - + + + Replay Запись игры - - + + Game Игра - - + + Player List Список игроков - - + + + Invite + + + + + Card Info Информация о карте - - + + Messages Сообщения - - + + Replay Timeline Полоса прокрутки записи - + &Phases &Фазы - + &Game &Игра - + Next &phase Следующая &фаза - + Next phase with &action - + Next &turn Следующий &ход - + Reverse turn order - + &Remove all local arrows &Удалить все указатели - + Rotate View Cl&ockwise Повернуть вид по часовой стрелке - + Rotate View Co&unterclockwise Повернуть вид против часовой стрелки - + Game &information Информация &об игре - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede &Сдаться... - + &Leave game Покинуть и&гру - + C&lose replay З&акрыть повтор - + &Focus Chat &Установить фокус на чат - + &Say: Ска&зать: - + Selected cards - + &View Вид - + Visible Видимый - + Floating Плавающий - + Reset layout Сбросить слой - + Concede Сдаться - + Are you sure you want to concede this game? Вы точно хотите сдаться? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Покинуть игру - + Are you sure you want to leave this game? Вы уверены, что хотите уйти? - + A player has joined game #%1 - + Игрок присоединился к игре №%1. - + %1 has joined the game - + %1 присоединился к игре - + You have been kicked out of the game. Вы были отключены от игры @@ -8845,164 +11307,164 @@ Please enter a name: Home - + Меню TabLog - + Logs Логи - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName Время;ИмяОтправителя;IPОтправителя;Сообщение;IDПолучателя;ИмяПолучателя - + Room Logs Логи Комнаты - + Game Logs Логи Игры - + Chat Logs Логи Чата - - + + Error Ошибка - + You must select at least one filter. Вы должны выбрать хотя бы один фильтр - + You have to select a valid number of days to locate. Вы должны указать верное количество дней для размещения. - + Username: Имя пользователя: - + IP Address: IP адрес - + Game Name: Имя игры: - + GameID: ID Игры: - + Message: Сообщение: - + Main Room Основная комната - + Game Room Игровая комната - + Private Chat Приватный чат - + Past X Days: Последние X Дней: - + Today Сегодня - + Last Hour За последний час - + Maximum Results: Максимальный результат: - + At least one filter is required. The more information you put in, the more specific your results will be. Должен быть как минимум один фильтр. Чем больше информации Вы укажете, тем точнее будет результат. - + Get User Logs Получить логи пользователя - + Clear Filters Очистить фильтры - + Filters Фильтры - + Log Locations Размещение логов - + Date Range Интервал дат - + Maximum Results Максимальный результат - - + + Message History История сообщений - + Failed to collect message history information. Не удалось собрать историю сообщений. - + There are no messages for the selected filters. Нет сообщений для указанных фильтров. @@ -9010,218 +11472,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat Приватный чат - + &Leave &Покинуть - + %1 - Private chat %1 - Приватный чат - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Это пользователь добавил Вас в игнор-лист и не видит ваши сообщения в главном чате, а Вы не можете присоединяться к созданным им (ею) играм. - + Private message from Личное сообщение от - + %1 has left the server. %1 отключился. - + %1 has joined the server. %1 зашел на сервер. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system Локальная файловая система - + Server replay storage Хранилище записей игр на сервере - - + + Watch replay Посмотреть повтор - + Rename - + Переименовать - - + + New folder - - + + Delete Удалить - + Open replays folder - + Download replay Скачать запись игры - + Toggle expiration lock Переключить блокировку срока - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Новое название: - + Error - + Ошибка - + Rename failed - + Name of new folder: - + Delete local file Удалить локальный файл - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Удалить запись игры @@ -9231,65 +12033,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + Готово. + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: &Сказать: - + Chat Чат - + &Room &Комната - + &Leave room &Покинуть комнату - + &Clear chat & Обновить чат - + Chat Settings... Установки чата - + mentioned you. упомянул(а) Вас. - + Click to view Нажмите для просмотра - + You are flooding the chat. Please wait a couple of seconds. Кажется, Вы нафлудили. Пожалуйста, подождите пару секунд. @@ -9297,35 +12512,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server Сервер - - - - + + + + Error Ошибка - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9333,97 +12548,173 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + EDHRec - + Archidekt - + Archidekt - + Home - + Меню - + &Visual Deck Storage - + Visual Database Display - + Server - + Сервер - + Account - + Учетная запись - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? Вы уверены? - + There are still open games. Are you sure you want to quit? У вас все еще есть активные игры. Уверены, что хотите выйти? + + + Connection quality over the last %n sample(s): + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + Вы заблокировали игрока %1. Чтобы написать ему/ей личное сообщение, разблокируйте его/её. + + + + Game #%1 + Игра №%1 + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Друг %1 подключился к серверу! - + Unknown Event Неопознанное событие - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9434,38 +12725,38 @@ To update your client, go to Help -> Check for Updates. Чтобы обновить клиент, перейдите в Справка-> Проверка обновлений. - + Idle Timeout Исчерпано время бездействия - + You are about to be logged out due to inactivity. Вы были отключены из-за отсутствия активности. - + Promotion Повышение - + You have been promoted. Please log out and back in for changes to take effect. - + Warned Предупрежден - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Вы получили предупреждение по причине %1. Пожалуйста, воздержитесь от подобных действий, иначе в ваш адрес будут применены дальнейшие меры. При возникновении каких-либо вопросов напишите личное сообщение любому модератору. - + You have received the following message from the server. (custom messages like these could be untranslated) Вы получили сообщение от сервера. @@ -9485,6 +12776,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9499,6 +12818,65 @@ Please refrain from engaging in this activity or further actions may be taken ag Невозможно проанализировать колоду + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + Тема: + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9519,42 +12897,42 @@ Please refrain from engaging in this activity or further actions may be taken ag Life - + Жизнь White - + Белый Blue - + Синий Black - + Черный Red - + Красный Green - + Зеленый Colorless - + Бесцветный Other - + Прочее @@ -9568,274 +12946,319 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Настройки картинки - + Type a card name... - + Введите название карты... - + Card name: + Название карты: + + + + Printing: + Издание: + + + + Card + Детали + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. - - Card ProviderId: - + + Banner + Баннер - + Left margin (%): - + Отступ слева (%): - + Right margin (%): - + Отступ справа (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card - + Удалить баннер UserContextMenu - + User &details &Детали пользователя - + Private &chat &Приватный чат - + Show this user's &games Показать &игры этого пользователя - + + View this user's &public decks + + + + Add to &buddy list Добавить в &список друзей - + Remove from &buddy list Удалить из &списка друзей - + Add to &ignore list Добавить в &игнор-лист - + Remove from &ignore list Удалить из &игнор-листа - + Kick from &game Выкинуть из &игры - - - Warn user - Дать предупреждение пользователю - - - - View user's war&n history - Просмотреть &историю предупреждений пользователя - - - - Ban from &server - Забанить на &сервере - - - - View user's &ban history - Просмотреть &историю банов пользователя - - - - &Promote user to moderator - &Повысить пользователя до модератора - - - - Dem&ote user from moderator - &Разжаловать пользователя из модератора - - - - Promote user to &judge - - - Demote user from judge + Report user + Warn user + Дать предупреждение пользователю + + + + View user's war&n history + Просмотреть &историю предупреждений пользователя + + + + Ban from &server + Забанить на &сервере + + + + View user's &ban history + Просмотреть &историю банов пользователя + + + + &Promote user to moderator + &Повысить пользователя до модератора + + + + Dem&ote user from moderator + &Разжаловать пользователя из модератора + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - - Error + Investigate user - + + + + Error + Ошибка + + + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Вы добавлены в игнор-лист игроком %1 и не можете видеть его/её игры. - + Could not get %1's games. - + %1's games Игры %1 - - - + + + Ban History История банов - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason ВремяБана;Модератор;ПродолжительностьБана;ПричинаБана;ВидимаяПричина - + User has never been banned. Пользователя никогда не банили. - + Failed to collect ban information. - - - + + + Warning History История предупреждений - + Warning Time;Moderator;User Name;Reason ВремяПредупреждения;Модератор;ИмяПольозователя;Причина - + User has never been warned. Пользователя никогда не предупреждали. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success Успешно - + Successfully promoted user. Пользователь успешно повышен. - + Successfully demoted user. Пользователь успешно понижен. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed Не удалось - + Failed to promote user. Не удалось повысить пользователя. - + Failed to demote user. Не удалось понизить пользователя. - + Copy hash to clipboard - + Remove this user's messages @@ -9873,361 +13296,370 @@ Please refrain from engaging in this activity or further actions may be taken ag Администратор - + Moderator Модератор - + Registered user Зарегистрированный пользователь - - + + Unregistered user Рандом - + Judge - + Unknown Неизвестный - + The entered password does not match your account. - + Введенный пароль неверен. - - - - + + + + Information Информация - + User information updated. Информация о пользователе обновлена. - - - - - - + + + + + - - - + + + + Error Ошибка User Information - + Информация о пользователе Real Name: - + Настоящее имя: User Level: - + Статус: Edit Banner Card + Сменить баннер + + + + Developer - + %n Year(s), amount of years (only shown if more than 0) - + %n год%n года%n лет%n лет - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + %10%n день %20%10%n дня %20%10%n дней %20%10%n дней %20 - + Enter Password - + Введите пароль - + Password verification is required in order to change your email address - + Для смены почтового ящика необходимо ввести пароль - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. Этот сервер не позволяет обновить пользовательскую информацию. - + Password changed. Пароль изменен. - + This server does not permit you to change your password. Этот сервер не позволяет Вам сменить пароль. - + The new password is too short. Новый пароль слишком короткий. - + The old password is incorrect. Предыдущий пароль неверен. - + Avatar updated. Аватар изменен. - + This server does not permit you to update your avatar. Этот сервер не позволяет Вам изменить аватар. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - Этот сервер не позволяет обновить пользовательскую информацию. - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + Удалить из списка друзей - + + Buddy - + Add to buddy list - + Добавить в список друзей - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Добавить в игнор-лист - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10235,223 +13667,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings Основные настройки интерфейса - + &Double-click cards to play them (instead of single-click) &Двойной клик, чтобы разыграть карту (вместо одинарного) - + &Clicking plays all selected cards (instead of just the clicked card) &Клик для разыгрывания всех выбранных карт (вместо только выбранной) - + &Play all nonlands onto the stack (not the battlefield) by default &Помещать все заклинания в стек при разыгрывании (вместо поля боя) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed Закрыть окно просмотра карт когда последняя карта удалена из него - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Изменить текст карты на токенах - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - + + + Enabled + + + + + + Automatic + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + Use tear-off menus, allowing right click menus to persist on screen Разрешить закрепление контекстных меню на экране - + Keep game chat focused when clicking in game (Note: disables card view search bar) - + Notifications settings - + Настройки оповещений - + Enable notifications in taskbar Включить оповещения в панели задач. - + Notify in the taskbar for game events while you are spectating Уведомлять об игровых событиях в панели задач, когда вы наблюдаете за игрой - + Notify in the taskbar when users in your buddy list connect Уведомлять при заходе в игру друга - + Animation settings Настройки анимации - + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation &Анимировать поворот/разворот карты - - Deck editor/storage settings - Редактор колод/Настройки хранилища + + &Arrow draw animation + - + + Life counter flash + + + + + Battlefield flash on damage + + + + + Deck editor/storage settings + Настройки редактора/хранилища колод + + + Open deck in new tab by default Открывать колоду в новой вкладке - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby Использовать графическое представление карт в лобби игры - + Use selection animation for Visual Deck Storage Использовать анимацию при выборе карты в графическом хранилище - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod - всегда конвертировать в .cod + + Always convert to .cod + - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: Время отката назад при нажатии шортката + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Пользователей на сервере: %1 - + Users in this room: %1 - + Пользователей в комнате: %1 - + Buddies online: %1 / %2 + Друзья онлайн: %1 / %2 + + + + Ignored users online: %1 / %2 + Игнорируемые пользователи онлайн: %1 / %2 + + + + Buddies - - Ignored users online: %1 / %2 + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) UtilityMenu - + Increment all card counters Добавить по одному каждого жетона - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10488,22 +14041,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10511,72 +14064,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10678,7 +14231,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10686,19 +14239,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10744,28 +14297,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10773,43 +14326,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10825,12 +14378,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10838,17 +14391,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10856,7 +14409,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10864,52 +14417,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10917,7 +14475,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10925,40 +14483,45 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Сорт. от А до Я (имя колоды) - + Sort Alphabetically (Filename) - + Сорт. от А до Я (имя файла) - + Sort by Last Modified - + Сорт. по дате посл. изменения - + Sort by Last Loaded - + Сорт. по дате посл. загрузки VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10966,177 +14529,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? Какое предупреждение Вы бы хотели отправить? - + Redact all messages from this user in all rooms - + &OK &ОК - + &Cancel &Отменить - + Warn user for misconduct Дать предупреждение пользователю за проступок - - + + Error Ошибка - + User name to send a warning to can not be blank, please specify a user to warn. Поле не может быть пустым. Пожалуйста, уточните имя пользователя для выдачи предупреждения. - + Warning to use can not be blank, please select a valid warning to send. Поле предупреждения не может быть пустым. Пожалуйста, выберите корректную причину. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top Переместить выбранное издание наверх - + Move selected set up Переместить выбранное издание вверх - + Move selected set down Переместить выбранное издание вниз - + Move selected set to the bottom Переместить выбранное издание в конец. - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets Включить все издания - + Disable all sets Отключить все издания - + Enable selected set(s) Включить выбранные издания - + Disable selected set(s) Отключить выбранные издания - + Deck Editor Редактор колод - + Use CTRL+A to select all sets in the view. - + Нажмите CTRL+A для выбора всех изданий в списке - + Only cards in enabled sets will appear in the card list of the deck editor. - + Только карты включенных изданий появятся в редакторе колод - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + + Search by set name, code, type, or release date + + + + How to use custom card art Как использовать кастомный арт для карты - + Hints - + Подсказки - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -11144,93 +14732,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Сгруппировать по мановой ценности - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing Перемешать после просмотра - + pile view просмотр вразнобой - - i18n - - - English - Русский (Russian) - - main - + Connect on startup - + Debug to file @@ -11281,7 +14861,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Move Selected Card - + Положить выбранную карту @@ -11291,12 +14871,12 @@ Please refrain from engaging in this activity or further actions may be taken ag Move Top Card - + Положить верхнюю карту Move Bottom Card - + Положить нижнюю карту @@ -11306,7 +14886,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Drawing - + Взятие карт @@ -11342,7 +14922,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Connect... - + Подключиться... @@ -11362,17 +14942,17 @@ Please refrain from engaging in this activity or further actions may be taken ag Register... - + Регистрация Settings... - + Настройки Start a Local Game... - + Начать локальную игру... @@ -11432,7 +15012,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Add Card - + Добавить карту @@ -11458,7 +15038,7 @@ Please refrain from engaging in this activity or further actions may be taken ag New Deck - + Новая колода @@ -11473,7 +15053,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Delete Card - + Удалить карту @@ -11484,12 +15064,12 @@ Please refrain from engaging in this activity or further actions may be taken ag Save Deck - + Сохранить колоду Save Deck as... - + Сохранить колоду как... @@ -11504,7 +15084,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Save Deck to Clipboard - + Сохранить колоду в буфер обмена @@ -11589,7 +15169,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Force Start - + Принудительный старт @@ -11839,37 +15419,37 @@ Please refrain from engaging in this activity or further actions may be taken ag First Main Phase - + Первая главная фаза Start Combat - + Шаг начала боя Attack - Атака + Шаг объявления атакующих Block - Блок + Шаг объявления блокирующих Damage - Повреждения + Шаг боевых повреждений End Combat - + Шаг конца боя Second Main Phase - + Вторая главная фаза @@ -11879,7 +15459,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Next Phase - + Следующая фаза @@ -11889,7 +15469,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Next Turn - + Следующий ход @@ -11904,7 +15484,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Untap All - + Развернуть все @@ -11950,7 +15530,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Create Token... - + Создать фишку... @@ -12014,6 +15594,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard Кладбище + + + Report Queue + + + + + Moderation + + @@ -12121,7 +15711,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Leave Game - + Покинуть игру @@ -12131,17 +15721,17 @@ Please refrain from engaging in this activity or further actions may be taken ag Roll Dice... - + Бросить кубик... Flip Coin - + Бросить монетку Shuffle Library - + Перетасовать библиотеку @@ -12181,7 +15771,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Undo Draw - + Отменить взятие карты @@ -12211,12 +15801,12 @@ Please refrain from engaging in this activity or further actions may be taken ag Reveal Hand to All Players - + Показать руку всем игрокам Reveal Random Card to All Players - + Показать случайную карту всем игрокам @@ -12281,7 +15871,7 @@ Please refrain from engaging in this activity or further actions may be taken ag Home - + Меню @@ -12296,12 +15886,12 @@ Please refrain from engaging in this activity or further actions may be taken ag Server - + Сервер Account - + Учетная запись diff --git a/oracle/translations/oracle_ru.ts b/oracle/translations/oracle_ru.ts index 4e1bcd3c6..475fdde74 100644 --- a/oracle/translations/oracle_ru.ts +++ b/oracle/translations/oracle_ru.ts @@ -2,215 +2,229 @@ IntroPage - + Introduction Введение - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. Эта программа импортирует перечень выпусков, карт и фишек, которые будут использоваться в Cockatrice. - + Interface language: Язык интерфейса: - + Version: - Версия + Версия: LoadSetsPage - + Source selection Выбор источника - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. Пожалуйста, укажите источник для перечня сетов и карт. Вы можете использовать ссылку или уже существующий локальный файл. - + Download URL: Ссылка на скачивание: - + Local file: Локальный файл: - + Restore default URL Восстановить ссылку по умолчанию - + Choose file... Выбрать файл.. - + Load sets file Загрузить файл сетов - + Sets file (%1) Sets JSON file (%1) Файл (%1) - - - - - - - + + + + + Error Ошибка - + The provided URL is not valid. Предоставленная ссылка некорректна. - - Downloading (0MB) - Загрузка (0MB) - - - + Please choose a file. Пожалуйста, выберите файл. - + Cannot open file '%1'. Не удалось открыть файл '%1'. - - Downloading (%1MB) - Загрузка (%1MB) - - - + Network error: %1. Ошибка сети: %1. - + Parsing file Ожидание файла - + Xz extraction failed. Ошибка распаковки архива - + Sorry, this version of Oracle does not support xz compressed files. Данная версия Oracle не поддерживает архивы xz - + Failed to open Zip archive: %1. Не удалось загрузить zip-архив: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. Ошибка распаковки: zip-архив содержит больше одного файла. - + Zip extraction failed: %1. Ошибка распаковки zip-архива: %1. - + Sorry, this version of Oracle does not support zipped files. Данная версия Oracle не поддерживает zip-архивы. - + Failed to interpret downloaded data. Ошибка в обработке загруженных данных - + + Downloading (0 MB) + Загрузка (0 MB) + + + + Downloading (%1 MB) + Загрузка (%1 MB) + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? Хотите вместо этого загрузить новую несжатую копию? - + + The file was retrieved successfully, but it does not contain any sets data. Файл успешно получен, но в нем не содержится данных о сетах. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database Сохранить базу карт - + XML; spoiler database (*.xml) XML; база карт (*.xml) - + spoiler - + Spoilers import Импорт карт - + Please specify a compatible source for spoiler data. Укажите совместимый ресурс для импорта базы карт - + Download URL: Ссылка на скачивание: - + Local file: - + Restore default URL Восстановить ссылку по умолчанию - + Choose file... - + The spoiler database will be saved at the following location: База карт будет сохранена в следующей директории: - + Save to a custom path (not recommended) @@ -218,57 +232,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + фишки - + Tokens import - + Please specify a compatible source for token data. - + Download URL: Ссылка на скачивание: - + Local file: - + Restore default URL Восстановить ссылку по умолчанию - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -276,7 +290,7 @@ OracleImporter - + Dummy set containing tokens Пример сета с фишками @@ -284,7 +298,7 @@ OracleWizard - + Oracle Importer Импортер Oracle @@ -292,22 +306,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -315,73 +329,76 @@ SaveSetsPage - - + + Error Ошибка - + + No set has been imported. Не было импортировано ни одного сета. - + Sets imported Импортировано сетов - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save Сохранить - + + Import finished: %1 cards. Импорт завершен: %1 карт. - + + %1: %2 cards imported %1: %2 карт импортировано - + Save card database Сохранить базу карт - + XML; card database (*.xml) База карт (*.xml) - + The file could not be saved to %1 Не удалось сохранить файл в %1 @@ -405,7 +422,7 @@ Error - + Ошибка @@ -414,18 +431,19 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) Please choose a file. - + Выберите файл. Cannot open file '%1'. - + Не удалось открыть файл '%1'. @@ -587,7 +605,7 @@ i18n - + English Русский (Russian) @@ -595,12 +613,12 @@ main - + Only run in spoiler mode Запускать только в режиме сетов - + Run in no-confirm background mode From fe8a3f486b01f85f6a2b8e9d0ce51bed86b00b4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:08:33 +0200 Subject: [PATCH 071/247] Bump actions/upload-artifact from 4 to 7 (#7574) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/desktop-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index beedaeaf2..0b9502fe3 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -225,7 +225,7 @@ jobs: - name: "Upload coverage artifact" if: matrix.coverage == 'yes' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report path: coverage/ From 4c03e0062b075d13cb86e14f83748da3044be993 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:35:27 +0200 Subject: [PATCH 072/247] [CI] Detect 64-bit Windows NSIS builds via pointer size, not generator platform (#7573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMAKE_GENERATOR_PLATFORM is Visual-Studio-only and stays empty under the Ninja generator that Windows CI switched to in #7380, so TRICE_IS_64_BIT was always 0 and every Windows artifact was mislabeled 32-bit. That fired the installer's 'installing a 32-bit version on a 64-bit system' warning and routed a genuinely 64-bit build into Program Files (x86) with the 32-bit registry view. Use CMAKE_SIZEOF_VOID_P, which reflects the actual target architecture for every generator (same test FindVCredistRuntime.cmake already uses). Co-authored-by: Lukas Brübach --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index bee47972c..87f1c746a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -327,7 +327,7 @@ if(UNIX) endif() elseif(WIN32) set(CPACK_GENERATOR NSIS ${CPACK_GENERATOR}) - if("${CMAKE_GENERATOR_PLATFORM}" MATCHES "(x64)") + if(CMAKE_SIZEOF_VOID_P EQUAL 8) set(TRICE_IS_64_BIT 1) else() set(TRICE_IS_64_BIT 0) From b849a57dab584b184e804527c2333e0688b9e590 Mon Sep 17 00:00:00 2001 From: RickyRister <42636155+RickyRister@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:10:26 -0700 Subject: [PATCH 073/247] [TabRoom] Fix compile failure due to unused lambda capture (#7577) --- cockatrice/src/interface/widgets/tabs/tab_room.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index e5f2020ca..f63b77445 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -365,7 +365,7 @@ void TabRoom::sendMessage() return; } else { ChatView *targetView = channelViews.value(activeChannelId, chatView); - auto onFinished = [this, targetView](const Response &response) { + auto onFinished = [targetView](const Response &response) { if (response.response_code() == Response::RespChatFlood) { targetView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds.")); } else if (response.response_code() == Response::RespFunctionNotAllowed || From a12b97ec50896e3103177cabc19ffd1fb8984efe Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 18 Oct 2026 14:29:51 +0200 Subject: [PATCH 074/247] [Translation] Move config into git version control (#7575) * Create transifex.yml * Update transifex.yml * Update and rename transifex.yml to transifex_github_config.yml * Update and rename transifex_github_config.yml to github_integration_config.yml --- .tx/github_integration_config.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .tx/github_integration_config.yml diff --git a/.tx/github_integration_config.yml b/.tx/github_integration_config.yml new file mode 100644 index 000000000..2586b4800 --- /dev/null +++ b/.tx/github_integration_config.yml @@ -0,0 +1,16 @@ +# Integration Docs: https://help.transifex.com/en/articles/6265125-github-installation-and-configuration#h_de29ef5cec +# Config Parameter: https://help.transifex.com/en/articles/6265125-github-installation-and-configuration#h_a734c3e219 +# Supported Files: https://help.transifex.com/en/collections/3519116-file-formats + +filters: + - filter_type: file + file_format: QT + source_language: en + source_file: cockatrice/cockatrice_en@source.ts + translation_files_expression: cockatrice/translations/cockatrice_.ts + + - filter_type: file + file_format: QT + source_language: en + source_file: oracle/oracle_en@source.ts + translation_files_expression: oracle/translations/oracle_.ts From 679762c624f9a5fbdc3c4b8ecb26905cfb27407f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:48:50 +0200 Subject: [PATCH 075/247] [Client] Settings: extract SettingsView from DlgSettings (#7482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Settings: extract SettingsView from DlgSettings Move the search bar, tab bar, stacked pages and search results out of DlgSettings into a reusable SettingsView widget. The dialog becomes a thin frame (title, OK button, close validation) delegating to the view. No visual or behavior change; tr() contexts preserved as DlgSettings. * [Client] Settings: match CI clang-format-18 line breaking --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/dialogs/dlg_settings.cpp | 462 +--------------- .../interface/widgets/dialogs/dlg_settings.h | 86 +-- .../widgets/dialogs/settings_view.cpp | 497 ++++++++++++++++++ .../interface/widgets/dialogs/settings_view.h | 98 ++++ 5 files changed, 625 insertions(+), 519 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/settings_view.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/settings_view.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e59326214..5baf10b1e 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -60,6 +60,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/path_safety_warning.cpp + src/interface/widgets/dialogs/settings_view.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp src/interface/widgets/deck_share/deck_share_utils.cpp src/interface/widgets/deck_share/shared_deck_preview_widget.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index d41c948eb..9b20372db 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -1,205 +1,41 @@ /** * @file dlg_settings.cpp - * @brief Implementation of the main settings dialog + * @brief Implementation of the main settings dialog (thin frame around SettingsView) * @ingroup Dialogs */ #include "dlg_settings.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" #include "../main.h" #include "../settings_page/abstract_settings_page.h" -#include "../settings_page/appearance_settings_page.h" -#include "../settings_page/deck_editor_settings_page.h" -#include "../settings_page/general_settings_page.h" -#include "../settings_page/messages_settings_page.h" -#include "../settings_page/settings_search_delegate.h" -#include "../settings_page/settings_search_model.h" -#include "../settings_page/shortcut_settings_page.h" -#include "../settings_page/sound_settings_page.h" -#include "../settings_page/storage_settings_page.h" -#include "../settings_page/user_interface_settings_page.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_manager.h" -#include -#include #include -#include -#include #include -#include -#include #include -#include -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include #include -#include #include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include #include #include #include #include #include -#include -class QModelIndex; - -/** - * @brief Wraps a widget in a scroll area for long settings pages - * @param widget The widget to wrap - * @return The scroll area containing the widget - */ -static QScrollArea *makeScrollable(QWidget *widget) -{ - widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); - - auto *scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); - scrollArea->setContentsMargins(0, 0, 0, 0); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->horizontalScrollBar()->setEnabled(false); - scrollArea->setWidget(widget); - return scrollArea; -} - -/** - * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order - */ -static QStringList pageIconResources() -{ - return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), - QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), - QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), - QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; -} - -DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false) +DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) { auto rec = QGuiApplication::primaryScreen()->availableGeometry(); setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height())); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); - setupUi(); - - connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); - retranslateUi(); - - searchEdit->setFocus(); - - adjustSize(); -} - -void DlgSettings::setupUi() -{ - // Search bar - searchEdit = new QLineEdit; - searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - searchEdit->installEventFilter(this); - connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged); - - auto *searchLayout = new QHBoxLayout; - searchLayout->addWidget(searchEdit); - - // Tab bar (built in setupTabBar) - setupTabBar(); - - // Pages stacked widget - pagesWidget = new QStackedWidget; - - auto *generalPage = new GeneralSettingsPage; - auto *appearancePage = new AppearanceSettingsPage; - auto *userInterfacePage = new UserInterfaceSettingsPage; - auto *deckEditorPage = new DeckEditorSettingsPage; - auto *storagePage = new StorageSettingsPage; - auto *messagesPage = new MessagesSettingsPage; - auto *soundPage = new SoundSettingsPage; - auto *shortcutsPage = new ShortcutSettingsPage; - - pages.append(generalPage); - pages.append(appearancePage); - pages.append(userInterfacePage); - pages.append(deckEditorPage); - pages.append(storagePage); - pages.append(messagesPage); - pages.append(soundPage); - pages.append(shortcutsPage); - - pagesWidget->addWidget(makeScrollable(generalPage)); - pagesWidget->addWidget(makeScrollable(appearancePage)); - pagesWidget->addWidget(makeScrollable(userInterfacePage)); - pagesWidget->addWidget(makeScrollable(deckEditorPage)); - pagesWidget->addWidget(makeScrollable(storagePage)); - pagesWidget->addWidget(makeScrollable(messagesPage)); - pagesWidget->addWidget(soundPage); - pagesWidget->addWidget(shortcutsPage); - - Q_ASSERT(pages.size() == NumPages); - - // Search results view (hidden by default) - searchResultsView = new QListView; - searchResultsView->setUniformItemSizes(false); - searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); - searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - searchResultsView->setVisible(false); - searchResultsView->setStyleSheet( - "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); - - searchModel = new SettingsSearchModel(this); - searchDelegate = new SettingsSearchDelegate(this); - searchResultsView->setModel(searchModel); - searchResultsView->setItemDelegate(searchDelegate); - connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked); - - connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { - const QStringList icons = pageIconResources(); - for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { - tabButtons[i]->setIcon(QPixmap(icons[i])); - } - searchDelegate->setPageIcons(icons); - searchResultsView->viewport()->update(); - }); - - // Build search index after pages are created - buildSearchIndex(); - - // Pages container (stacked widget + search results overlay) - pagesContainer = new QWidget; - auto *containerLayout = new QStackedLayout; - containerLayout->setStackingMode(QStackedLayout::StackAll); - containerLayout->addWidget(pagesWidget); - containerLayout->addWidget(searchResultsView); - pagesContainer->setLayout(containerLayout); + settingsView = new SettingsView(this); // Bottom buttons auto *buttonBox = new QHBoxLayout; @@ -211,241 +47,25 @@ void DlgSettings::setupUi() // Main layout auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(searchLayout); - mainLayout->addWidget(tabBarWidget); - auto *separator = new QFrame; - separator->setFrameShape(QFrame::HLine); - separator->setFrameShadow(QFrame::Sunken); - mainLayout->addWidget(separator); - mainLayout->addWidget(pagesContainer); + mainLayout->addWidget(settingsView); mainLayout->addSpacing(4); mainLayout->addLayout(buttonBox); setLayout(mainLayout); - // Keyboard shortcuts - auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); - connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); + retranslateUi(); - auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); - connect(nextTabShortcut, &QShortcut::activated, this, [this] { - int next = (currentTabIndex + 1) % tabButtons.size(); - setActiveTab(next); - }); - - auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); - connect(prevTabShortcut, &QShortcut::activated, this, [this] { - int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); - setActiveTab(prev); - }); - - // Initialize to first tab - setActiveTab(0); -} - -void DlgSettings::setupTabBar() -{ - tabBarWidget = new QWidget; - auto *tabLayout = new QHBoxLayout; - tabLayout->setContentsMargins(0, 0, 0, 0); - tabLayout->setSpacing(2); - - const QStringList iconResources = pageIconResources(); - - for (int i = 0; i < iconResources.size(); ++i) { - auto *tabButton = new QToolButton; - tabButton->setCheckable(true); - tabButton->setIcon(QPixmap(iconResources[i])); - tabButton->setIconSize(QSize(48, 48)); - tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); - tabButton->setAutoExclusive(true); - tabButton->setMinimumHeight(85); - tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - - connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); - - tabButtons.append(tabButton); - tabLayout->addWidget(tabButton); - } - - tabBarWidget->setLayout(tabLayout); -} - -void DlgSettings::buildSearchIndex() -{ - QList allEntries; - - const QStringList pageNames = translatedPageNames(); - searchDelegate->setPageNames(pageNames); - searchDelegate->setPageIcons(pageIconResources()); - - for (int i = 0; i < pages.size(); ++i) { - QList pageEntries = pages[i]->getSearchEntries(); - for (auto &entry : pageEntries) { - if (entry.pageIndex == -1) { - entry.pageIndex = i; - } - } - allEntries.append(pageEntries); - } - - searchModel->setSourceEntries(allEntries); -} - -void DlgSettings::onTabClicked(int index) -{ - if (searchActive) { - switchToTabMode(); - } - setActiveTab(index); -} - -void DlgSettings::setActiveTab(int index) -{ - if (index < 0 || index >= tabButtons.size()) { - return; - } - - currentTabIndex = index; - pagesWidget->setCurrentIndex(index); - - for (int i = 0; i < tabButtons.size(); ++i) { - tabButtons[i]->setChecked(i == index); - } - - // Style active tab with a thick accent border + subtle background tint - for (int i = 0; i < tabButtons.size(); ++i) { - if (i == index) { - tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " - "border-top-left-radius: 4px; border-top-right-radius: 4px; " - "background: palette(window); padding-bottom: 1px; }"); - } else { - tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " - "border-top-left-radius: 4px; border-top-right-radius: 4px; " - "background: transparent; }"); - } - } -} - -void DlgSettings::flashWidget(QWidget *widget) -{ - auto *overlay = new QWidget(widget); - overlay->setGeometry(widget->rect()); - overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); - - QPalette pal = overlay->palette(); - QColor flashColor = pal.color(QPalette::Highlight); - flashColor.setAlpha(100); - pal.setBrush(QPalette::Window, flashColor); - overlay->setPalette(pal); - overlay->setAutoFillBackground(true); - - auto *effect = new QGraphicsOpacityEffect(overlay); - effect->setOpacity(0.0); - overlay->setGraphicsEffect(effect); - overlay->show(); - overlay->raise(); - - auto *flashIn = new QPropertyAnimation(effect, "opacity"); - flashIn->setDuration(120); - flashIn->setStartValue(0.0); - flashIn->setEndValue(0.6); - flashIn->setEasingCurve(QEasingCurve::OutCubic); - - auto *fadeOut = new QPropertyAnimation(effect, "opacity"); - fadeOut->setDuration(900); - fadeOut->setStartValue(0.6); - fadeOut->setEndValue(0.0); - fadeOut->setEasingCurve(QEasingCurve::InCubic); - - auto *group = new QSequentialAnimationGroup(overlay); - group->addAnimation(flashIn); - group->addAnimation(fadeOut); - - connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); - - group->start(QAbstractAnimation::DeleteWhenStopped); -} - -void DlgSettings::onSearchTextChanged(const QString &text) -{ - searchModel->setFilterString(text); - - if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { - if (!searchActive) { - switchToSearchMode(); - } - if (searchModel->rowCount(QModelIndex()) > 0) { - searchResultsView->setCurrentIndex(searchModel->index(0)); - } - } else if (searchActive) { - switchToTabMode(); - } -} - -void DlgSettings::switchToSearchMode() -{ - searchActive = true; - tabBarWidget->setVisible(false); - pagesWidget->setVisible(false); - searchResultsView->setVisible(true); - if (searchModel->rowCount(QModelIndex()) > 0) { - searchResultsView->setCurrentIndex(searchModel->index(0)); - } -} - -void DlgSettings::switchToTabMode() -{ - searchActive = false; - tabBarWidget->setVisible(true); - pagesWidget->setVisible(true); - searchResultsView->setVisible(false); - searchEdit->blockSignals(true); - searchEdit->clear(); - searchEdit->blockSignals(false); - setActiveTab(currentTabIndex); -} - -void DlgSettings::onSearchResultClicked(const QModelIndex &index) -{ - navigateToSearchResult(index); -} - -void DlgSettings::navigateToSearchResult(const QModelIndex &index) -{ - SettingsSearchEntry entry = searchModel->entryForIndex(index); - if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { - return; - } - - // Switch to the page - switchToTabMode(); - setActiveTab(entry.pageIndex); - - // Scroll to the widget, focus it, and flash to highlight it - if (entry.widget) { - QWidget *widget = entry.widget; - while (widget) { - if (auto *scrollArea = qobject_cast(widget)) { - scrollArea->ensureWidgetVisible(entry.widget); - break; - } - widget = widget->parentWidget(); - } - entry.widget->setFocus(); - flashWidget(entry.widget); - } + adjustSize(); } void DlgSettings::setTab(int index) { - if (index >= 0 && index < tabButtons.size()) { - setActiveTab(index); - } + settingsView->setTab(index); } AbstractSettingsPage *DlgSettings::page(SettingsPage which) const { - return pages.value(static_cast(which)); + return settingsView->page(which); } void DlgSettings::updateLanguage() @@ -454,49 +74,6 @@ void DlgSettings::updateLanguage() installNewTranslator(); } -bool DlgSettings::eventFilter(QObject *watched, QEvent *event) -{ - if (watched == searchEdit && event->type() == QEvent::KeyPress) { - auto *keyEvent = static_cast(event); - if (keyEvent->key() == Qt::Key_Escape) { - if (searchActive) { - switchToTabMode(); - return true; - } - } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { - if (searchActive) { - if (searchResultsView->currentIndex().isValid()) { - navigateToSearchResult(searchResultsView->currentIndex()); - } - return true; - } - } else if (keyEvent->key() == Qt::Key_Down) { - if (searchActive) { - int nextRow = searchResultsView->currentIndex().row() + 1; - if (nextRow >= searchModel->rowCount()) { - nextRow = 0; - } - searchResultsView->setCurrentIndex(searchModel->index(nextRow)); - searchResultsView->scrollTo(searchModel->index(nextRow)); - return true; - } - } else if (keyEvent->key() == Qt::Key_Up) { - if (searchActive) { - int prevRow = searchResultsView->currentIndex().row() - 1; - if (prevRow < 0) { - prevRow = searchModel->rowCount() - 1; - } - if (prevRow >= 0) { - searchResultsView->setCurrentIndex(searchModel->index(prevRow)); - searchResultsView->scrollTo(searchModel->index(prevRow)); - } - return true; - } - } - } - return QDialog::eventFilter(watched, event); -} - void DlgSettings::closeEvent(QCloseEvent *event) { bool showLoadError = true; @@ -576,26 +153,5 @@ void DlgSettings::closeEvent(QCloseEvent *event) void DlgSettings::retranslateUi() { setWindowTitle(tr("Settings")); - retranslateTabNames(); - - searchEdit->setPlaceholderText(tr("Search settings...")); okButton->setText(tr("OK")); - - // Rebuild search index for translated text - buildSearchIndex(); -} - -QStringList DlgSettings::translatedPageNames() -{ - return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"), - tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")}; -} - -void DlgSettings::retranslateTabNames() -{ - const QStringList tabLabels = translatedPageNames(); - - for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { - tabButtons[i]->setText(tabLabels[i]); - } } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index 1f15e76d5..9942b410c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -1,105 +1,59 @@ /** * @file dlg_settings.h - * @brief Main settings dialog for the Cockatrice client + * @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView) * @ingroup Dialogs */ #ifndef DLG_SETTINGS_H #define DLG_SETTINGS_H +#include "settings_view.h" + #include -#include #include -#include -#include #include class QPushButton; -class QModelIndex; -class QWidget; +class AbstractSettingsPage; inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); -class QStackedWidget; -class QToolButton; -class QListView; -class QLineEdit; -class AbstractSettingsPage; -class SettingsSearchModel; -class SettingsSearchDelegate; - /** - * @brief Main application settings dialog with tabbed navigation and search + * @brief Main application settings dialog hosting the shared SettingsView * - * Provides a modern settings interface organized into tabbed pages. Users can - * either navigate by clicking tabs or search for specific settings using the - * built-in search bar. Search results are filtered and ranked by relevance. + * Thin QDialog frame around SettingsView: only the window title, the OK button + * and the close validation live here. All pages, navigation and search are + * owned by SettingsView so other hosts (e.g. a settings tab) can reuse them. */ class DlgSettings : public QDialog { Q_OBJECT public: - /** - * @brief Page order in the tab bar, matching the order pages are added in setupUi() - * - * Use these values instead of raw indices so reordering pages never silently - * breaks external callers like tab_room.cpp. - */ - enum SettingsPage - { - GeneralPage = 0, - AppearancePage, - UserInterfacePage, - DeckEditorPage, - StoragePage, - MessagesPage, - SoundPage, - ShortcutsPage, - NumPages - }; + using SettingsPage = SettingsView::SettingsPage; + static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage; + static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage; + static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage; + static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage; + static constexpr SettingsPage StoragePage = SettingsView::StoragePage; + static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage; + static constexpr SettingsPage SoundPage = SettingsView::SoundPage; + static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage; + static constexpr SettingsPage NumPages = SettingsView::NumPages; explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); AbstractSettingsPage *page(SettingsPage which) const; private slots: - void onTabClicked(int index); - void onSearchTextChanged(const QString &text); - void onSearchResultClicked(const QModelIndex &index); void updateLanguage(); private: - // UI elements - QLineEdit *searchEdit; ///< Search bar for filtering settings - QWidget *tabBarWidget; ///< Container widget for the tab buttons - QList tabButtons; ///< Navigation tab buttons - QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages - QListView *searchResultsView; ///< Search results list view - QWidget *pagesContainer; ///< Container stacking pages and search results - QPushButton *okButton; ///< Button to close the dialog - - // Data - QList pages; ///< All settings page instances - SettingsSearchModel *searchModel; ///< Model for search results - SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering - int currentTabIndex; ///< Currently active tab index - bool searchActive; ///< Whether search mode is active - - void setupUi(); - void setupTabBar(); - void buildSearchIndex(); - void switchToTabMode(); - void switchToSearchMode(); - void navigateToSearchResult(const QModelIndex &index); - void setActiveTab(int index); - static void flashWidget(QWidget *widget); - static QStringList translatedPageNames(); + SettingsView *settingsView; ///< Shared settings content + QPushButton *okButton; ///< Button to close the dialog void retranslateUi(); - void retranslateTabNames(); protected: void closeEvent(QCloseEvent *event) override; - bool eventFilter(QObject *watched, QEvent *event) override; }; #endif // DLG_SETTINGS_H diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp new file mode 100644 index 000000000..c2d9860c3 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -0,0 +1,497 @@ +/** + * @file settings_view.cpp + * @brief Implementation of the settings content widget + * @ingroup Dialogs + */ +#include "settings_view.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" +#include "../settings_page/abstract_settings_page.h" +#include "../settings_page/appearance_settings_page.h" +#include "../settings_page/deck_editor_settings_page.h" +#include "../settings_page/general_settings_page.h" +#include "../settings_page/messages_settings_page.h" +#include "../settings_page/settings_search_delegate.h" +#include "../settings_page/settings_search_model.h" +#include "../settings_page/shortcut_settings_page.h" +#include "../settings_page/sound_settings_page.h" +#include "../settings_page/storage_settings_page.h" +#include "../settings_page/user_interface_settings_page.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Wraps a widget in a scroll area for long settings pages + * @param widget The widget to wrap + * @return The scroll area containing the widget + */ +static QScrollArea *makeScrollable(QWidget *widget) +{ + widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); + + auto *scrollArea = new QScrollArea; + scrollArea->setWidgetResizable(true); + scrollArea->setContentsMargins(0, 0, 0, 0); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->horizontalScrollBar()->setEnabled(false); + scrollArea->setWidget(widget); + return scrollArea; +} + +/** + * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order + */ +static QStringList pageIconResources() +{ + return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), + QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), + QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), + QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; +} + +SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false) +{ + setupUi(); + + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi); + retranslateUi(); + + searchEdit->setFocus(); +} + +void SettingsView::setupUi() +{ + // Search bar + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->installEventFilter(this); + connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); + + auto *searchLayout = new QHBoxLayout; + searchLayout->addWidget(searchEdit); + + // Tab bar (built in setupTabBar) + setupTabBar(); + + // Pages stacked widget + pagesWidget = new QStackedWidget; + + auto *generalPage = new GeneralSettingsPage; + auto *appearancePage = new AppearanceSettingsPage; + auto *userInterfacePage = new UserInterfaceSettingsPage; + auto *deckEditorPage = new DeckEditorSettingsPage; + auto *storagePage = new StorageSettingsPage; + auto *messagesPage = new MessagesSettingsPage; + auto *soundPage = new SoundSettingsPage; + auto *shortcutsPage = new ShortcutSettingsPage; + + pages.append(generalPage); + pages.append(appearancePage); + pages.append(userInterfacePage); + pages.append(deckEditorPage); + pages.append(storagePage); + pages.append(messagesPage); + pages.append(soundPage); + pages.append(shortcutsPage); + + pagesWidget->addWidget(makeScrollable(generalPage)); + pagesWidget->addWidget(makeScrollable(appearancePage)); + pagesWidget->addWidget(makeScrollable(userInterfacePage)); + pagesWidget->addWidget(makeScrollable(deckEditorPage)); + pagesWidget->addWidget(makeScrollable(storagePage)); + pagesWidget->addWidget(makeScrollable(messagesPage)); + pagesWidget->addWidget(soundPage); + pagesWidget->addWidget(shortcutsPage); + + Q_ASSERT(pages.size() == NumPages); + + // Search results view (hidden by default) + searchResultsView = new QListView; + searchResultsView->setUniformItemSizes(false); + searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); + searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + searchResultsView->setVisible(false); + searchResultsView->setStyleSheet( + "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); + + searchModel = new SettingsSearchModel(this); + searchDelegate = new SettingsSearchDelegate(this); + searchResultsView->setModel(searchModel); + searchResultsView->setItemDelegate(searchDelegate); + connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked); + + connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { + const QStringList icons = pageIconResources(); + for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { + tabButtons[i]->setIcon(QPixmap(icons[i])); + } + searchDelegate->setPageIcons(icons); + searchResultsView->viewport()->update(); + }); + + // Build search index after pages are created + buildSearchIndex(); + + // Pages container (stacked widget + search results overlay) + pagesContainer = new QWidget; + auto *containerLayout = new QStackedLayout; + containerLayout->setStackingMode(QStackedLayout::StackAll); + containerLayout->addWidget(pagesWidget); + containerLayout->addWidget(searchResultsView); + pagesContainer->setLayout(containerLayout); + + // Main layout + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(searchLayout); + mainLayout->addWidget(tabBarWidget); + auto *separator = new QFrame; + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + mainLayout->addWidget(separator); + mainLayout->addWidget(pagesContainer); + mainLayout->addSpacing(4); + setLayout(mainLayout); + + // Keyboard shortcuts + auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); + connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); + + auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); + connect(nextTabShortcut, &QShortcut::activated, this, [this] { + int next = (currentTabIndex + 1) % tabButtons.size(); + setActiveTab(next); + }); + + auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); + connect(prevTabShortcut, &QShortcut::activated, this, [this] { + int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); + setActiveTab(prev); + }); + + // Initialize to first tab + setActiveTab(0); +} + +void SettingsView::setupTabBar() +{ + tabBarWidget = new QWidget; + auto *tabLayout = new QHBoxLayout; + tabLayout->setContentsMargins(0, 0, 0, 0); + tabLayout->setSpacing(2); + + const QStringList iconResources = pageIconResources(); + + for (int i = 0; i < iconResources.size(); ++i) { + auto *tabButton = new QToolButton; + tabButton->setCheckable(true); + tabButton->setIcon(QPixmap(iconResources[i])); + tabButton->setIconSize(QSize(48, 48)); + tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + tabButton->setAutoExclusive(true); + tabButton->setMinimumHeight(85); + tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); + + tabButtons.append(tabButton); + tabLayout->addWidget(tabButton); + } + + tabBarWidget->setLayout(tabLayout); +} + +void SettingsView::buildSearchIndex() +{ + QList allEntries; + + const QStringList pageNames = translatedPageNames(); + searchDelegate->setPageNames(pageNames); + searchDelegate->setPageIcons(pageIconResources()); + + for (int i = 0; i < pages.size(); ++i) { + QList pageEntries = pages[i]->getSearchEntries(); + for (auto &entry : pageEntries) { + if (entry.pageIndex == -1) { + entry.pageIndex = i; + } + } + allEntries.append(pageEntries); + } + + searchModel->setSourceEntries(allEntries); +} + +void SettingsView::onTabClicked(int index) +{ + if (searchActive) { + switchToTabMode(); + } + setActiveTab(index); +} + +void SettingsView::setActiveTab(int index) +{ + if (index < 0 || index >= tabButtons.size()) { + return; + } + + currentTabIndex = index; + pagesWidget->setCurrentIndex(index); + + for (int i = 0; i < tabButtons.size(); ++i) { + tabButtons[i]->setChecked(i == index); + } + + // Style active tab with a thick accent border + subtle background tint + for (int i = 0; i < tabButtons.size(); ++i) { + if (i == index) { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: palette(window); padding-bottom: 1px; }"); + } else { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: transparent; }"); + } + } +} + +void SettingsView::flashWidget(QWidget *widget) +{ + auto *overlay = new QWidget(widget); + overlay->setGeometry(widget->rect()); + overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + QPalette pal = overlay->palette(); + QColor flashColor = pal.color(QPalette::Highlight); + flashColor.setAlpha(100); + pal.setBrush(QPalette::Window, flashColor); + overlay->setPalette(pal); + overlay->setAutoFillBackground(true); + + auto *effect = new QGraphicsOpacityEffect(overlay); + effect->setOpacity(0.0); + overlay->setGraphicsEffect(effect); + overlay->show(); + overlay->raise(); + + auto *flashIn = new QPropertyAnimation(effect, "opacity"); + flashIn->setDuration(120); + flashIn->setStartValue(0.0); + flashIn->setEndValue(0.6); + flashIn->setEasingCurve(QEasingCurve::OutCubic); + + auto *fadeOut = new QPropertyAnimation(effect, "opacity"); + fadeOut->setDuration(900); + fadeOut->setStartValue(0.6); + fadeOut->setEndValue(0.0); + fadeOut->setEasingCurve(QEasingCurve::InCubic); + + auto *group = new QSequentialAnimationGroup(overlay); + group->addAnimation(flashIn); + group->addAnimation(fadeOut); + + connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); + + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void SettingsView::onSearchTextChanged(const QString &text) +{ + searchModel->setFilterString(text); + + if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { + if (!searchActive) { + switchToSearchMode(); + } + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } + } else if (searchActive) { + switchToTabMode(); + } +} + +void SettingsView::switchToSearchMode() +{ + searchActive = true; + tabBarWidget->setVisible(false); + pagesWidget->setVisible(false); + searchResultsView->setVisible(true); + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } +} + +void SettingsView::switchToTabMode() +{ + searchActive = false; + tabBarWidget->setVisible(true); + pagesWidget->setVisible(true); + searchResultsView->setVisible(false); + searchEdit->blockSignals(true); + searchEdit->clear(); + searchEdit->blockSignals(false); + setActiveTab(currentTabIndex); +} + +void SettingsView::onSearchResultClicked(const QModelIndex &index) +{ + navigateToSearchResult(index); +} + +void SettingsView::navigateToSearchResult(const QModelIndex &index) +{ + SettingsSearchEntry entry = searchModel->entryForIndex(index); + if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { + return; + } + + // Switch to the page + switchToTabMode(); + setActiveTab(entry.pageIndex); + + // Scroll to the widget, focus it, and flash to highlight it + if (entry.widget) { + QWidget *widget = entry.widget; + while (widget) { + if (auto *scrollArea = qobject_cast(widget)) { + scrollArea->ensureWidgetVisible(entry.widget); + break; + } + widget = widget->parentWidget(); + } + entry.widget->setFocus(); + flashWidget(entry.widget); + } +} + +void SettingsView::setTab(int index) +{ + if (index >= 0 && index < tabButtons.size()) { + setActiveTab(index); + } +} + +AbstractSettingsPage *SettingsView::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + +bool SettingsView::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == searchEdit && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Escape) { + if (searchActive) { + switchToTabMode(); + return true; + } + } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { + if (searchActive) { + if (searchResultsView->currentIndex().isValid()) { + navigateToSearchResult(searchResultsView->currentIndex()); + } + return true; + } + } else if (keyEvent->key() == Qt::Key_Down) { + if (searchActive) { + int nextRow = searchResultsView->currentIndex().row() + 1; + if (nextRow >= searchModel->rowCount()) { + nextRow = 0; + } + searchResultsView->setCurrentIndex(searchModel->index(nextRow)); + searchResultsView->scrollTo(searchModel->index(nextRow)); + return true; + } + } else if (keyEvent->key() == Qt::Key_Up) { + if (searchActive) { + int prevRow = searchResultsView->currentIndex().row() - 1; + if (prevRow < 0) { + prevRow = searchModel->rowCount() - 1; + } + if (prevRow >= 0) { + searchResultsView->setCurrentIndex(searchModel->index(prevRow)); + searchResultsView->scrollTo(searchModel->index(prevRow)); + } + return true; + } + } + } + return QWidget::eventFilter(watched, event); +} + +void SettingsView::retranslateUi() +{ + retranslateTabNames(); + + // NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings + // before the view was extracted, and keeping the context preserves existing translations. + searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings...")); + + // Rebuild search index for translated text + buildSearchIndex(); +} + +QStringList SettingsView::translatedPageNames() +{ + // NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi). + return {QCoreApplication::translate("DlgSettings", "General"), + QCoreApplication::translate("DlgSettings", "Appearance"), + QCoreApplication::translate("DlgSettings", "User Interface"), + QCoreApplication::translate("DlgSettings", "Card Sources"), + QCoreApplication::translate("DlgSettings", "Storage"), + QCoreApplication::translate("DlgSettings", "Chat"), + QCoreApplication::translate("DlgSettings", "Sound"), + QCoreApplication::translate("DlgSettings", "Shortcuts")}; +} + +void SettingsView::retranslateTabNames() +{ + const QStringList tabLabels = translatedPageNames(); + + for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { + tabButtons[i]->setText(tabLabels[i]); + } +} diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h new file mode 100644 index 000000000..fe1b925d8 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.h @@ -0,0 +1,98 @@ +/** + * @file settings_view.h + * @brief Settings content widget hosted by the settings dialog (and, later, the settings tab) + * @ingroup Dialogs + */ +#ifndef SETTINGS_VIEW_H +#define SETTINGS_VIEW_H + +#include +#include +#include +#include +#include + +class QModelIndex; +class QObject; +class QEvent; + +class QStackedWidget; +class QToolButton; +class QListView; +class QLineEdit; +class AbstractSettingsPage; +class SettingsSearchModel; +class SettingsSearchDelegate; + +/** + * @brief Settings pages with tabbed navigation and search, without any dialog chrome + * + * Owns the search bar, the tab bar, the stacked settings pages and the search + * results view. Dialog/tab hosts only add their own frame (buttons, titles). + */ +class SettingsView : public QWidget +{ + Q_OBJECT +public: + /** + * @brief Page order in the tab bar, matching the order pages are added in setupUi() + * + * Use these values instead of raw indices so reordering pages never silently + * breaks external callers like tab_room.cpp. + */ + enum SettingsPage + { + GeneralPage = 0, + AppearancePage, + UserInterfacePage, + DeckEditorPage, + StoragePage, + MessagesPage, + SoundPage, + ShortcutsPage, + NumPages + }; + + explicit SettingsView(QWidget *parent = nullptr); + void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; + +private slots: + void onTabClicked(int index); + void onSearchTextChanged(const QString &text); + void onSearchResultClicked(const QModelIndex &index); + +private: + // UI elements + QLineEdit *searchEdit; ///< Search bar for filtering settings + QWidget *tabBarWidget; ///< Container widget for the tab buttons + QList tabButtons; ///< Navigation tab buttons + QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages + QListView *searchResultsView; ///< Search results list view + QWidget *pagesContainer; ///< Container stacking pages and search results + + // Data + QList pages; ///< All settings page instances + SettingsSearchModel *searchModel; ///< Model for search results + SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering + int currentTabIndex; ///< Currently active tab index + bool searchActive; ///< Whether search mode is active + + void setupUi(); + void setupTabBar(); + void buildSearchIndex(); + void switchToTabMode(); + void switchToSearchMode(); + void navigateToSearchResult(const QModelIndex &index); + void setActiveTab(int index); + static void flashWidget(QWidget *widget); + static QStringList translatedPageNames(); + + void retranslateUi(); + void retranslateTabNames(); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; +}; + +#endif // SETTINGS_VIEW_H From 665f7267e4df61f80b13d5b38153a56278d77411 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:48:50 +0200 Subject: [PATCH 076/247] [Client] Settings: enlarge the settings dialog (#7483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Settings: enlarge the settings dialog Keep settings modal per review: size to ~90% of the main window (5% margins each side) with screen fallback, size grip and 750x700 minimum bounded to the screen. Drops the tab approach while giving dense pages room without the tab-switch friction. * [Client] Settings: clamp dialog width to 1280 per review * [Client] Settings: extract dialog size calculation per review Move the host/screen target math out of the DlgSettings constructor into a calculateDialogSize helper. Drop the leftover 'per review' wording from the clamp comment. No behavior change. --------- Co-authored-by: Lukas Brübach --- .../widgets/dialogs/dlg_settings.cpp | 46 +++++++++++++++++-- .../interface/widgets/dialogs/dlg_settings.h | 3 +- .../interface/widgets/dialogs/settings_view.h | 4 +- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index 9b20372db..ab870a2c8 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -28,10 +29,49 @@ #include #include +static constexpr int MIN_DIALOG_WIDTH = 750; +static constexpr int MIN_DIALOG_HEIGHT = 700; +static constexpr int MAX_DIALOG_WIDTH = 1280; +static constexpr double DIALOG_SIZE_RATIO = 0.9; + +/** + * @brief Calculates the initial dialog size from the host window or screen + * @param hostSize Size of the hosting main window, or empty when parentless + * @param availableSize Available screen area the dialog must fit into + * @param minimumSize Minimum dialog size already applied via setMinimumSize + * @return Target size at ~90% of the host with small margins, clamped to fit + */ +static QSize calculateDialogSize(const QSize &hostSize, const QSize &availableSize, const QSize &minimumSize) +{ + QSize target = !hostSize.isEmpty() ? QSize(qRound(hostSize.width() * DIALOG_SIZE_RATIO), + qRound(hostSize.height() * DIALOG_SIZE_RATIO)) + : QSize(qRound(availableSize.width() * DIALOG_SIZE_RATIO), + qRound(availableSize.height() * DIALOG_SIZE_RATIO)); + // Evenly-spaced columns leave unnatural dead space on very wide windows, so clamp the width. + target.setWidth(qMin(target.width(), MAX_DIALOG_WIDTH)); + return target.expandedTo(minimumSize).boundedTo(availableSize); +} + DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) { - auto rec = QGuiApplication::primaryScreen()->availableGeometry(); - setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height())); + // Fill the main window with small margins so multi-group pages (e.g. Deck Editor) are not buried in a scroll. + // 90% of the host window leaves ~5% margins on each side; falls back to 90% of the screen when parentless. + QWidget *hostWindow = parent ? parent->window() : nullptr; + QScreen *screen = nullptr; + QSize hostSize; + if (hostWindow) { + hostSize = hostWindow->size(); + screen = QGuiApplication::screenAt(hostWindow->geometry().center()); + } + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + const QRect available = screen ? screen->availableGeometry() : QRect(0, 0, 1280, 800); + const QSize minimum(qMin(MIN_DIALOG_WIDTH, available.width()), qMin(MIN_DIALOG_HEIGHT, available.height())); + setMinimumSize(minimum); + + resize(calculateDialogSize(hostSize, available.size(), minimum)); + setSizeGripEnabled(true); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); @@ -54,8 +94,6 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); retranslateUi(); - - adjustSize(); } void DlgSettings::setTab(int index) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index 9942b410c..03ccd6b7e 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -22,7 +22,8 @@ inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); * * Thin QDialog frame around SettingsView: only the window title, the OK button * and the close validation live here. All pages, navigation and search are - * owned by SettingsView so other hosts (e.g. a settings tab) can reuse them. + * owned by SettingsView. The dialog fills ~90% of the main window so dense + * pages stay visible without losing the modal adjust-and-return flow. */ class DlgSettings : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h index fe1b925d8..f1d248679 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.h +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.h @@ -1,6 +1,6 @@ /** * @file settings_view.h - * @brief Settings content widget hosted by the settings dialog (and, later, the settings tab) + * @brief Settings content widget hosted by the settings dialog * @ingroup Dialogs */ #ifndef SETTINGS_VIEW_H @@ -28,7 +28,7 @@ class SettingsSearchDelegate; * @brief Settings pages with tabbed navigation and search, without any dialog chrome * * Owns the search bar, the tab bar, the stacked settings pages and the search - * results view. Dialog/tab hosts only add their own frame (buttons, titles). + * results view. The settings dialog only adds its own frame (buttons, titles). */ class SettingsView : public QWidget { From 860625be4df3a9fef8cdcd559c010653a5e26c49 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:48:50 +0200 Subject: [PATCH 077/247] [Client] Settings: grouped left navigation for settings pages (#7484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Settings: grouped left navigation for settings pages Replace the top icon-button bar in SettingsView with a grouped left QTreeWidget: Application, Cards and Data, Chat and Sound, Advanced. The active page is the selected row (palette highlight); search mode hides the tree; group headers expand but never select. Page tr() contexts stay DlgSettings; new group names are SettingsView strings. Reworked to stay in the enlarged modal instead of the settings tab. * [Settings] Make groupedPages static const per review --------- Co-authored-by: Lukas Brübach --- .../widgets/dialogs/settings_view.cpp | 146 +++++++++++------- .../interface/widgets/dialogs/settings_view.h | 27 ++-- 2 files changed, 101 insertions(+), 72 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index c2d9860c3..483aa31bf 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -28,7 +28,6 @@ #include #include #include -#include #include #include #include @@ -53,7 +52,7 @@ #include #include #include -#include +#include #include #include #include @@ -111,8 +110,8 @@ void SettingsView::setupUi() auto *searchLayout = new QHBoxLayout; searchLayout->addWidget(searchEdit); - // Tab bar (built in setupTabBar) - setupTabBar(); + // Navigation tree (built in setupNavTree) + setupNavTree(); // Pages stacked widget pagesWidget = new QStackedWidget; @@ -163,8 +162,10 @@ void SettingsView::setupUi() connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { const QStringList icons = pageIconResources(); - for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { - tabButtons[i]->setIcon(QPixmap(icons[i])); + for (int i = 0; i < pageItems.size() && i < icons.size(); ++i) { + if (pageItems[i]) { + pageItems[i]->setIcon(0, QPixmap(icons[i])); + } } searchDelegate->setPageIcons(icons); searchResultsView->viewport()->update(); @@ -181,16 +182,14 @@ void SettingsView::setupUi() containerLayout->addWidget(searchResultsView); pagesContainer->setLayout(containerLayout); - // Main layout + // Main layout: search on top, navigation + pages side by side below + auto *contentLayout = new QHBoxLayout; + contentLayout->addWidget(navTree); + contentLayout->addWidget(pagesContainer, 1); + auto *mainLayout = new QVBoxLayout; mainLayout->addLayout(searchLayout); - mainLayout->addWidget(tabBarWidget); - auto *separator = new QFrame; - separator->setFrameShape(QFrame::HLine); - separator->setFrameShadow(QFrame::Sunken); - mainLayout->addWidget(separator); - mainLayout->addWidget(pagesContainer); - mainLayout->addSpacing(4); + mainLayout->addLayout(contentLayout); setLayout(mainLayout); // Keyboard shortcuts @@ -199,13 +198,13 @@ void SettingsView::setupUi() auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); connect(nextTabShortcut, &QShortcut::activated, this, [this] { - int next = (currentTabIndex + 1) % tabButtons.size(); + int next = (currentTabIndex + 1) % pages.size(); setActiveTab(next); }); auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); connect(prevTabShortcut, &QShortcut::activated, this, [this] { - int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); + int prev = (currentTabIndex - 1 + pages.size()) % pages.size(); setActiveTab(prev); }); @@ -213,32 +212,56 @@ void SettingsView::setupUi() setActiveTab(0); } -void SettingsView::setupTabBar() +void SettingsView::setupNavTree() { - tabBarWidget = new QWidget; - auto *tabLayout = new QHBoxLayout; - tabLayout->setContentsMargins(0, 0, 0, 0); - tabLayout->setSpacing(2); + navTree = new QTreeWidget; + navTree->setHeaderHidden(true); + navTree->setRootIsDecorated(true); + navTree->setIconSize(QSize(24, 24)); + navTree->setMinimumWidth(230); + navTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + navTree->setStyleSheet( + "QTreeWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); + // Groups of SettingsPage values, in navigation order + static const QList> groupedPages = { + {GeneralPage, AppearancePage, UserInterfacePage}, + {DeckEditorPage, StoragePage}, + {MessagesPage, SoundPage}, + {ShortcutsPage}, + }; + + const QStringList groupNames = translatedGroupNames(); + const QStringList pageNames = translatedPageNames(); const QStringList iconResources = pageIconResources(); - for (int i = 0; i < iconResources.size(); ++i) { - auto *tabButton = new QToolButton; - tabButton->setCheckable(true); - tabButton->setIcon(QPixmap(iconResources[i])); - tabButton->setIconSize(QSize(48, 48)); - tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); - tabButton->setAutoExclusive(true); - tabButton->setMinimumHeight(85); - tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - - connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); - - tabButtons.append(tabButton); - tabLayout->addWidget(tabButton); + pageItems.resize(NumPages); + for (int i = 0; i < groupedPages.size(); ++i) { + auto *group = new QTreeWidgetItem(navTree, {groupNames[i]}); + group->setFlags(Qt::ItemIsEnabled); // header row: expandable, not selectable + group->setExpanded(true); + groupItems.append(group); + for (SettingsPage page : groupedPages[i]) { + auto *item = new QTreeWidgetItem(group, {pageNames[page]}); + item->setIcon(0, QPixmap(iconResources[page])); + item->setData(0, Qt::UserRole, static_cast(page)); + pageItems[page] = item; + } } - tabBarWidget->setLayout(tabLayout); + Q_ASSERT(groupItems.size() == groupedPages.size()); + Q_ASSERT(!pageItems.contains(nullptr)); + + connect(navTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current) { + if (!current) { + return; + } + bool ok = false; + int index = current->data(0, Qt::UserRole).toInt(&ok); + if (ok) { + onTabClicked(index); + } + }); } void SettingsView::buildSearchIndex() @@ -266,35 +289,27 @@ void SettingsView::onTabClicked(int index) { if (searchActive) { switchToTabMode(); + } else if (index == currentTabIndex) { + return; } setActiveTab(index); } void SettingsView::setActiveTab(int index) { - if (index < 0 || index >= tabButtons.size()) { + if (index < 0 || index >= pageItems.size() || !pageItems[index]) { return; } currentTabIndex = index; pagesWidget->setCurrentIndex(index); - for (int i = 0; i < tabButtons.size(); ++i) { - tabButtons[i]->setChecked(i == index); - } - - // Style active tab with a thick accent border + subtle background tint - for (int i = 0; i < tabButtons.size(); ++i) { - if (i == index) { - tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " - "border-top-left-radius: 4px; border-top-right-radius: 4px; " - "background: palette(window); padding-bottom: 1px; }"); - } else { - tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " - "border-top-left-radius: 4px; border-top-right-radius: 4px; " - "background: transparent; }"); - } + // Selecting the item also expands a collapsed group; selecting the already + // current item emits nothing, so the currentItemChanged handler cannot loop. + if (QTreeWidgetItem *parent = pageItems[index]->parent()) { + parent->setExpanded(true); } + navTree->setCurrentItem(pageItems[index]); } void SettingsView::flashWidget(QWidget *widget) @@ -356,7 +371,7 @@ void SettingsView::onSearchTextChanged(const QString &text) void SettingsView::switchToSearchMode() { searchActive = true; - tabBarWidget->setVisible(false); + navTree->setVisible(false); pagesWidget->setVisible(false); searchResultsView->setVisible(true); if (searchModel->rowCount(QModelIndex()) > 0) { @@ -367,7 +382,7 @@ void SettingsView::switchToSearchMode() void SettingsView::switchToTabMode() { searchActive = false; - tabBarWidget->setVisible(true); + navTree->setVisible(true); pagesWidget->setVisible(true); searchResultsView->setVisible(false); searchEdit->blockSignals(true); @@ -409,7 +424,7 @@ void SettingsView::navigateToSearchResult(const QModelIndex &index) void SettingsView::setTab(int index) { - if (index >= 0 && index < tabButtons.size()) { + if (index >= 0 && index < pageItems.size()) { setActiveTab(index); } } @@ -464,7 +479,7 @@ bool SettingsView::eventFilter(QObject *watched, QEvent *event) void SettingsView::retranslateUi() { - retranslateTabNames(); + retranslateNavNames(); // NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings // before the view was extracted, and keeping the context preserves existing translations. @@ -487,11 +502,22 @@ QStringList SettingsView::translatedPageNames() QCoreApplication::translate("DlgSettings", "Shortcuts")}; } -void SettingsView::retranslateTabNames() +void SettingsView::retranslateNavNames() { - const QStringList tabLabels = translatedPageNames(); + const QStringList groupNames = translatedGroupNames(); + for (int i = 0; i < groupItems.size() && i < groupNames.size(); ++i) { + groupItems[i]->setText(0, groupNames[i]); + } - for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { - tabButtons[i]->setText(tabLabels[i]); + const QStringList pageNames = translatedPageNames(); + for (int i = 0; i < pageItems.size() && i < pageNames.size(); ++i) { + if (pageItems[i]) { + pageItems[i]->setText(0, pageNames[i]); + } } } + +QStringList SettingsView::translatedGroupNames() +{ + return {tr("Application"), tr("Cards & Data"), tr("Chat & Sound"), tr("Advanced")}; +} diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h index f1d248679..214353660 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.h +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.h @@ -17,7 +17,8 @@ class QObject; class QEvent; class QStackedWidget; -class QToolButton; +class QTreeWidget; +class QTreeWidgetItem; class QListView; class QLineEdit; class AbstractSettingsPage; @@ -25,9 +26,9 @@ class SettingsSearchModel; class SettingsSearchDelegate; /** - * @brief Settings pages with tabbed navigation and search, without any dialog chrome + * @brief Settings pages with grouped left navigation and search, without any dialog chrome * - * Owns the search bar, the tab bar, the stacked settings pages and the search + * Owns the search bar, the navigation tree, the stacked settings pages and the search * results view. The settings dialog only adds its own frame (buttons, titles). */ class SettingsView : public QWidget @@ -35,7 +36,7 @@ class SettingsView : public QWidget Q_OBJECT public: /** - * @brief Page order in the tab bar, matching the order pages are added in setupUi() + * @brief Page order in the navigation, matching the order pages are added in setupUi() * * Use these values instead of raw indices so reordering pages never silently * breaks external callers like tab_room.cpp. @@ -64,12 +65,13 @@ private slots: private: // UI elements - QLineEdit *searchEdit; ///< Search bar for filtering settings - QWidget *tabBarWidget; ///< Container widget for the tab buttons - QList tabButtons; ///< Navigation tab buttons - QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages - QListView *searchResultsView; ///< Search results list view - QWidget *pagesContainer; ///< Container stacking pages and search results + QLineEdit *searchEdit; ///< Search bar for filtering settings + QTreeWidget *navTree; ///< Grouped navigation tree for the pages + QList groupItems; ///< Group header rows, indexed by group + QList pageItems; ///< Page rows, indexed by SettingsPage + QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages + QListView *searchResultsView; ///< Search results list view + QWidget *pagesContainer; ///< Container stacking pages and search results // Data QList pages; ///< All settings page instances @@ -79,7 +81,7 @@ private: bool searchActive; ///< Whether search mode is active void setupUi(); - void setupTabBar(); + void setupNavTree(); void buildSearchIndex(); void switchToTabMode(); void switchToSearchMode(); @@ -87,9 +89,10 @@ private: void setActiveTab(int index); static void flashWidget(QWidget *widget); static QStringList translatedPageNames(); + static QStringList translatedGroupNames(); void retranslateUi(); - void retranslateTabNames(); + void retranslateNavNames(); protected: bool eventFilter(QObject *watched, QEvent *event) override; From 4974cac59af26c8885bf90005b58faec8e059313 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:48:51 +0200 Subject: [PATCH 078/247] [Client] Settings: add top-right cogwheel shortcut (#7485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Settings: add top-right cogwheel shortcut Add a subtle flat settings button to the tab bar corner that opens the settings dialog via the existing menu action slot. Icon and tooltip follow the menu action in retranslateUi. * [Client] Settings: flat black-cogwheel corner entry per review --------- Co-authored-by: Lukas Brübach --- cockatrice/src/interface/window_main.cpp | 18 ++++++++++++++++++ cockatrice/src/interface/window_main.h | 2 ++ 2 files changed, 20 insertions(+) diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index d23ce254c..636cee281 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -82,10 +82,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -355,6 +357,9 @@ void MainWindow::retranslateUi() aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); + settingsButton->setToolTip(tr("Settings")); + settingsButton->setAccessibleName(tr("Settings")); + settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ @@ -559,6 +564,19 @@ MainWindow::MainWindow(QWidget *parent) setCentralWidget(tabSupervisor); + // Unobtrusive settings entry in the tab bar corner: flat icon-only button that blends into the tab + // bar until hovered, mirroring the menu action for discoverability + settingsButton = new QToolButton(tabSupervisor); + settingsButton->setAutoRaise(true); + settingsButton->setToolButtonStyle(Qt::ToolButtonIconOnly); + settingsButton->setIconSize(QSize(16, 16)); + settingsButton->setCursor(Qt::PointingHandCursor); + settingsButton->setFocusPolicy(Qt::NoFocus); + settingsButton->setStyleSheet( + QStringLiteral("QToolButton { border: none; background: transparent; padding: 4px; }")); + connect(settingsButton, &QToolButton::clicked, this, &MainWindow::actSettings); + tabSupervisor->setCornerWidget(settingsButton, Qt::TopRightCorner); + retranslateUi(); if (!restoreGeometry(SettingsCache::instance().layouts().getMainWindowGeometry())) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 4b3da716d..bcf709cb1 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -46,6 +46,7 @@ #include class QAction; +class QToolButton; class QWidget; struct LocalGameOptions; @@ -178,6 +179,7 @@ private: QList tabMenus; QMenu *cockatriceMenu, *dbMenu, *tabsMenu, *helpMenu, *trayIconMenu; QAction *aAbout, *aSettings, *aShow, *aExit; + QToolButton *settingsButton = nullptr; QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen; QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet, *aReloadCardDatabase; From 4750e67b7fe109adfa6225525f563bd7a1ff3850 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:48:51 +0200 Subject: [PATCH 079/247] [Client] Settings: sub-page rows deep-linking into page sections (#7487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AbstractSettingsPage::getSections built from each page's group boxes. Show each multi-group page's sections as child rows in the navigation tree. Section rows show the parent page and scroll to and highlight the group box. Start pages collapsed. Keep single-group pages flat. Reworked to stay in the enlarged modal instead of the settings tab. Co-authored-by: Lukas Brübach --- .../widgets/dialogs/settings_view.cpp | 102 +++++++++++++++--- .../interface/widgets/dialogs/settings_view.h | 2 + .../settings_page/abstract_settings_page.cpp | 24 +++++ .../settings_page/abstract_settings_page.h | 17 +++ 4 files changed, 132 insertions(+), 13 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 483aa31bf..3feda61b5 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -88,6 +89,33 @@ static QStringList pageIconResources() QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; } +namespace +{ +/// Item data role holding a sub-page row's section widget, stored as a WidgetId +constexpr int SectionWidgetRole = Qt::UserRole + 1; +using WidgetId = quintptr; + +/** + * @brief Packs a section widget for storage in a tree item's data role + * @param widget The section's group box (owned by its settings page) + * @return The widget as an opaque id + */ +WidgetId toWidgetId(QWidget *widget) +{ + return reinterpret_cast(widget); +} + +/** + * @brief Unpacks a section widget from a tree item's data role + * @param id The stored id, or 0 for page rows without a section + * @return The section widget, or nullptr when unset + */ +QWidget *fromWidgetId(WidgetId id) +{ + return reinterpret_cast(id); +} +} // namespace + SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false) { setupUi(); @@ -110,9 +138,6 @@ void SettingsView::setupUi() auto *searchLayout = new QHBoxLayout; searchLayout->addWidget(searchEdit); - // Navigation tree (built in setupNavTree) - setupNavTree(); - // Pages stacked widget pagesWidget = new QStackedWidget; @@ -145,6 +170,9 @@ void SettingsView::setupUi() Q_ASSERT(pages.size() == NumPages); + // Navigation tree (needs the pages for their sections) + setupNavTree(); + // Search results view (hidden by default) searchResultsView = new QListView; searchResultsView->setUniformItemSizes(false); @@ -246,6 +274,12 @@ void SettingsView::setupNavTree() item->setIcon(0, QPixmap(iconResources[page])); item->setData(0, Qt::UserRole, static_cast(page)); pageItems[page] = item; + // Sub-page rows deep-link into the page's sections (scroll + highlight) + for (const SettingsPageSection §ion : pages[page]->getSections()) { + auto *sectionItem = new QTreeWidgetItem(item, {section.title}); + sectionItem->setData(0, Qt::UserRole, static_cast(page)); + sectionItem->setData(0, SectionWidgetRole, QVariant::fromValue(toWidgetId(section.sectionWidget))); + } } } @@ -258,7 +292,12 @@ void SettingsView::setupNavTree() } bool ok = false; int index = current->data(0, Qt::UserRole).toInt(&ok); - if (ok) { + if (!ok) { + return; + } + if (QWidget *section = fromWidgetId(current->data(0, SectionWidgetRole).value())) { + navigateToSection(index, section); + } else { onTabClicked(index); } }); @@ -409,19 +448,44 @@ void SettingsView::navigateToSearchResult(const QModelIndex &index) // Scroll to the widget, focus it, and flash to highlight it if (entry.widget) { - QWidget *widget = entry.widget; - while (widget) { - if (auto *scrollArea = qobject_cast(widget)) { - scrollArea->ensureWidgetVisible(entry.widget); - break; - } - widget = widget->parentWidget(); - } + scrollToAndFlash(entry.widget); entry.widget->setFocus(); - flashWidget(entry.widget); } } +void SettingsView::navigateToSection(int pageIndex, QWidget *sectionWidget) +{ + if (pageIndex < 0 || pageIndex >= pages.size() || !sectionWidget) { + return; + } + + if (currentTabIndex != pageIndex) { + if (searchActive) { + switchToTabMode(); + } + setActiveTab(pageIndex); + } + + // Focus stays in the navigation tree; only scroll and highlight the section + scrollToAndFlash(sectionWidget); +} + +void SettingsView::scrollToAndFlash(QWidget *target) +{ + if (!target) { + return; + } + QWidget *widget = target; + while (widget) { + if (auto *scrollArea = qobject_cast(widget)) { + scrollArea->ensureWidgetVisible(target); + break; + } + widget = widget->parentWidget(); + } + flashWidget(target); +} + void SettingsView::setTab(int index) { if (index >= 0 && index < pageItems.size()) { @@ -515,6 +579,18 @@ void SettingsView::retranslateNavNames() pageItems[i]->setText(0, pageNames[i]); } } + + // Sub-page rows mirror the pages' group box titles (already retranslated by the pages) + for (int i = 0; i < pageItems.size() && i < pages.size(); ++i) { + if (!pageItems[i]) { + continue; + } + const QList sections = pages[i]->getSections(); + Q_ASSERT(pageItems[i]->childCount() == sections.size()); + for (int c = 0; c < pageItems[i]->childCount() && c < sections.size(); ++c) { + pageItems[i]->child(c)->setText(0, sections[c].title); + } + } } QStringList SettingsView::translatedGroupNames() diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h index 214353660..8d188bf51 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.h +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.h @@ -86,8 +86,10 @@ private: void switchToTabMode(); void switchToSearchMode(); void navigateToSearchResult(const QModelIndex &index); + void navigateToSection(int pageIndex, QWidget *sectionWidget); void setActiveTab(int index); static void flashWidget(QWidget *widget); + static void scrollToAndFlash(QWidget *target); static QStringList translatedPageNames(); static QStringList translatedGroupNames(); diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp index 9c43d1f7b..063f1b2a8 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp @@ -137,6 +137,30 @@ QList AbstractSettingsPage::getSearchEntries() return autoDetectSearchEntries(this, -1); } +QList AbstractSettingsPage::getSections() +{ + QList sections; + + // Walk the top-level layout in display order (creation order differs on some pages) + if (QLayout *mainLayout = layout()) { + for (int i = 0; i < mainLayout->count(); ++i) { + QLayoutItem *item = mainLayout->itemAt(i); + QWidget *widget = item ? item->widget() : nullptr; + auto *groupBox = qobject_cast(widget); + if (!groupBox || groupBox->title().isEmpty()) { + continue; + } + sections.append({groupBox->title(), groupBox}); + } + } + + // A single group is the page itself; sub-page entries would only duplicate it + if (sections.size() < 2) { + sections.clear(); + } + return sections; +} + QList AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex) { QList entries; diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h index 722a91e62..c38a5214b 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h @@ -2,6 +2,7 @@ #define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H #include +#include #include #include @@ -11,12 +12,28 @@ struct SettingsSearchEntry; +/** + * @brief A navigable section of a settings page (one group box) + */ +struct SettingsPageSection +{ + QString title; ///< Translated group title + QWidget *sectionWidget = nullptr; ///< Group box to scroll to and highlight +}; + class AbstractSettingsPage : public QWidget { Q_OBJECT public: virtual void retranslateUi() = 0; virtual QList getSearchEntries(); + /** + * @brief Sections of this page for sub-page navigation, in display order + * + * Scans the page's direct-child group boxes; pages with a single group + * report no sections so the nav stays flat for them. + */ + QList getSections(); protected: static QList autoDetectSearchEntries(QWidget *page, int pageIndex); From 43435445881a846de0561f4be24cb726ef983393 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:48:52 +0200 Subject: [PATCH 080/247] [Client] Settings: isolate the selected section when drilling down (#7488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Settings: isolate the selected section when drilling down Show only the selected section's group box. Restore the overview on page rows. Filter search results to the control's containing section. Reset the scroll position on every change. Reworked to stay in the enlarged modal instead of the settings tab. * [Client] Settings: re-clicking an isolated section restores the overview * [Settings] Keep section row selected when drilling down per review * [Settings] Stop stealing nav selection mid-gesture per review * [Settings] Sample nav selection pre-press so re-click detection works per review * Don't flash the section on drill-down * [Client] Settings: clear the section filter when selecting the page row per review onTabClicked() returned early for the already-current page before switchPageContent() could call applySectionFilter(index, nullptr), so clicking the page row while a section was isolated moved the selection but left the page filtered. Only skip the restore when no section is isolated. * [Settings] Select the search result's section in the sidebar per review Reaching a setting through the search isolated its section and flashed the widget, but left the navigation selection on the page row, so the sidebar looked like it had only jumped to the section's page. Extract the section-row selection shared with drill-down into selectSectionRow() and use it for search results too, keeping the gesture/re-click behavior untouched. --------- Co-authored-by: Lukas Brübach --- .../widgets/dialogs/settings_view.cpp | 177 +++++++++++++++++- .../interface/widgets/dialogs/settings_view.h | 6 + 2 files changed, 175 insertions(+), 8 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 3feda61b5..ebac4e22f 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -251,6 +253,13 @@ void SettingsView::setupNavTree() navTree->setStyleSheet( "QTreeWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); + // Re-click detection has to sample the selection before the view handles the + // press: QAbstractItemView updates the current item (emitting + // currentItemChanged) and only then emits itemPressed, so itemPressed always + // sees the pressed item as "current" and cannot tell a re-click from a first + // click. The viewport event filter runs before QAbstractItemView::mousePressEvent. + navTree->viewport()->installEventFilter(this); + // Groups of SettingsPage values, in navigation order static const QList> groupedPages = { {GeneralPage, AppearancePage, UserInterfacePage}, @@ -301,6 +310,26 @@ void SettingsView::setupNavTree() onTabClicked(index); } }); + + connect(navTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item) { + const bool wasReClick = pressWasOnCurrentItem; + pressWasOnCurrentItem = false; + // A fresh click already navigated via currentItemChanged; only a press that + // started on the already-current item (captured by the viewport event filter + // before the view updated the selection) is a re-click. Re-clicking the + // isolated section restores the full page. + if (!wasReClick || item == nullptr || item != navTree->currentItem() || isolatedSection == nullptr || + fromWidgetId(item->data(0, SectionWidgetRole).value()) != isolatedSection) { + return; + } + bool ok = false; + const int index = item->data(0, Qt::UserRole).toInt(&ok); + if (ok) { + // setActiveTab, not onTabClicked: the page is already current and + // only setActiveTab clears the section filter + setActiveTab(index); + } + }); } void SettingsView::buildSearchIndex() @@ -328,7 +357,9 @@ void SettingsView::onTabClicked(int index) { if (searchActive) { switchToTabMode(); - } else if (index == currentTabIndex) { + } else if (index == currentTabIndex && isolatedSection == nullptr) { + // Re-selecting the current page row is a no-op, but it must still clear + // an isolated section so the page's widgets come back. return; } setActiveTab(index); @@ -340,15 +371,28 @@ void SettingsView::setActiveTab(int index) return; } - currentTabIndex = index; - pagesWidget->setCurrentIndex(index); + switchPageContent(index); // Selecting the item also expands a collapsed group; selecting the already // current item emits nothing, so the currentItemChanged handler cannot loop. + navTree->setCurrentItem(pageItems[index]); +} + +void SettingsView::switchPageContent(int index) +{ + if (index < 0 || index >= pageItems.size() || !pageItems[index]) { + return; + } + + currentTabIndex = index; + pagesWidget->setCurrentIndex(index); + + // A page row always shows the full page; sections filter down from here + applySectionFilter(index, nullptr); + if (QTreeWidgetItem *parent = pageItems[index]->parent()) { parent->setExpanded(true); } - navTree->setCurrentItem(pageItems[index]); } void SettingsView::flashWidget(QWidget *widget) @@ -446,8 +490,19 @@ void SettingsView::navigateToSearchResult(const QModelIndex &index) switchToTabMode(); setActiveTab(entry.pageIndex); - // Scroll to the widget, focus it, and flash to highlight it + // Isolate the control's section, then scroll to the widget, focus it, and flash it if (entry.widget) { + QWidget *section = nullptr; + for (const SettingsPageSection &candidate : pages[entry.pageIndex]->getSections()) { + if (candidate.sectionWidget == entry.widget || + (candidate.sectionWidget && candidate.sectionWidget->isAncestorOf(entry.widget))) { + section = candidate.sectionWidget; + break; + } + } + applySectionFilter(entry.pageIndex, section); + // Keep the sidebar in sync with the deep link: highlight the setting's section row + selectSectionRow(entry.pageIndex, section); scrollToAndFlash(entry.widget); entry.widget->setFocus(); } @@ -459,15 +514,110 @@ void SettingsView::navigateToSection(int pageIndex, QWidget *sectionWidget) return; } + // Never steal the view's own selection mid-gesture: when this runs from + // currentItemChanged the section row is already current (mouse press or arrow key), + // and re-selecting the page row here is what made the selection visibly snap back + // to the parent on mouse release. selectSectionRow only acts on a known section + // that is not current yet, so it syncs programmatic callers without disturbing a gesture. if (currentTabIndex != pageIndex) { if (searchActive) { switchToTabMode(); } - setActiveTab(pageIndex); + const QSignalBlocker blocker(navTree); + switchPageContent(pageIndex); + } + applySectionFilter(pageIndex, sectionWidget); + + // Unknown widgets show the full page; known sections keep their row selected + selectSectionRow(pageIndex, sectionWidget); +} + +/** + * @brief Selects the nav row for a section, expanding its page row + * + * A null or unknown section (a page whose sections are its own group boxes) leaves + * the current selection untouched, so callers can pass a lookup result directly. + * + * @param pageIndex SettingsPage owning the section + * @param sectionWidget Section's group box, or nullptr for no section + */ +void SettingsView::selectSectionRow(int pageIndex, QWidget *sectionWidget) +{ + if (!sectionWidget) { + return; } - // Focus stays in the navigation tree; only scroll and highlight the section - scrollToAndFlash(sectionWidget); + QTreeWidgetItem *sectionItem = findSectionItem(pageIndex, sectionWidget); + if (!sectionItem) { + return; + } + + pageItems[pageIndex]->setExpanded(true); + if (navTree->currentItem() != sectionItem) { + const QSignalBlocker blocker(navTree); + navTree->setCurrentItem(sectionItem); + } +} + +/** + * @brief Finds the nav row for a section widget + * @param pageIndex SettingsPage owning the section + * @param sectionWidget Section's group box + * @return The section's tree item, or nullptr when it has no row + */ +QTreeWidgetItem *SettingsView::findSectionItem(int pageIndex, QWidget *sectionWidget) const +{ + if (pageIndex < 0 || pageIndex >= pageItems.size() || !pageItems[pageIndex]) { + return nullptr; + } + for (int c = 0; c < pageItems[pageIndex]->childCount(); ++c) { + QTreeWidgetItem *child = pageItems[pageIndex]->child(c); + if (fromWidgetId(child->data(0, SectionWidgetRole).value()) == sectionWidget) { + return child; + } + } + return nullptr; +} + +/** + * @brief Shows only the given section of a page, hiding its siblings + * + * A null sectionWidget restores the full page. Unknown widgets fall back to + * the full page so a stale reference can never leave a blank page. + * + * @param pageIndex SettingsPage to filter + * @param sectionWidget Section's group box, or nullptr to show all sections + */ +void SettingsView::applySectionFilter(int pageIndex, QWidget *sectionWidget) +{ + if (pageIndex < 0 || pageIndex >= pages.size()) { + return; + } + + const QList sections = pages[pageIndex]->getSections(); + + bool known = sectionWidget == nullptr; + for (const SettingsPageSection §ion : sections) { + if (section.sectionWidget == sectionWidget) { + known = true; + } + } + if (!known) { + sectionWidget = nullptr; + } + isolatedSection = sectionWidget; + + for (const SettingsPageSection §ion : sections) { + if (section.sectionWidget) { + section.sectionWidget->setVisible(sectionWidget == nullptr || section.sectionWidget == sectionWidget); + } + } + + if (auto *scrollArea = qobject_cast(pagesWidget->widget(pageIndex))) { + if (QScrollBar *scrollBar = scrollArea->verticalScrollBar()) { + scrollBar->setValue(0); + } + } } void SettingsView::scrollToAndFlash(QWidget *target) @@ -500,6 +650,17 @@ AbstractSettingsPage *SettingsView::page(SettingsPage which) const bool SettingsView::eventFilter(QObject *watched, QEvent *event) { + if (watched == navTree->viewport()) { + if (event->type() == QEvent::MouseButtonPress) { + auto *mouseEvent = static_cast(event); + // Left button only: context presses must never toggle the filter + QTreeWidgetItem *pressedItem = navTree->itemAt(mouseEvent->position().toPoint()); + pressWasOnCurrentItem = mouseEvent->button() == Qt::LeftButton && pressedItem != nullptr && + pressedItem == navTree->currentItem(); + } + return false; // observation only: the view still handles the press + } + if (watched == searchEdit && event->type() == QEvent::KeyPress) { auto *keyEvent = static_cast(event); if (keyEvent->key() == Qt::Key_Escape) { diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h index 8d188bf51..90dc5636f 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.h +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.h @@ -79,6 +79,8 @@ private: SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering int currentTabIndex; ///< Currently active tab index bool searchActive; ///< Whether search mode is active + QWidget *isolatedSection = nullptr; ///< Isolated section group box, or nullptr for full pages + bool pressWasOnCurrentItem = false; ///< True when the mouse press started on the already-current item void setupUi(); void setupNavTree(); @@ -88,6 +90,10 @@ private: void navigateToSearchResult(const QModelIndex &index); void navigateToSection(int pageIndex, QWidget *sectionWidget); void setActiveTab(int index); + void switchPageContent(int index); + void applySectionFilter(int pageIndex, QWidget *sectionWidget); + void selectSectionRow(int pageIndex, QWidget *sectionWidget); + QTreeWidgetItem *findSectionItem(int pageIndex, QWidget *sectionWidget) const; static void flashWidget(QWidget *widget); static void scrollToAndFlash(QWidget *target); static QStringList translatedPageNames(); From 46cdbac830b69584bdc3bb61998c401c5eb28d43 Mon Sep 17 00:00:00 2001 From: Zach H Date: Sun, 18 Oct 2026 16:50:04 -0400 Subject: [PATCH 081/247] [Servatrice] Match trusted_sources by address, not substring (#7582) trusted_sources was checked with QString::contains. A dual-stack listener reports a local proxy as ::ffff:127.0.0.1, which never matches the default "127.0.0.1,::1". Behind nginx the forwarded client IP header was then ignored, every websocket user shared the proxy's address, and logins failed with "too many concurrent connections" once max_users_per_address was reached. Add isTrustedSource(), which parses each entry as a QHostAddress and compares exact addresses with IPv4-mapped addresses normalized, and use it at all four call sites. The ban command also read server/trusted_sources instead of the documented security/trusted_sources; it now uses the same getter. Entries that are not IP addresses are logged at startup since they no longer match anything. Co-authored-by: Claude Opus 5.5 --- servatrice/CMakeLists.txt | 1 + servatrice/servatrice.ini.example | 3 +- servatrice/src/servatrice.cpp | 17 ++++++- servatrice/src/servatrice.h | 2 + servatrice/src/serversocketinterface.cpp | 10 ++-- servatrice/src/trusted_sources.cpp | 23 ++++++++++ servatrice/src/trusted_sources.h | 20 ++++++++ tests/CMakeLists.txt | 11 +++++ tests/trusted_sources_test.cpp | 58 ++++++++++++++++++++++++ 9 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 servatrice/src/trusted_sources.cpp create mode 100644 servatrice/src/trusted_sources.h create mode 100644 tests/trusted_sources_test.cpp diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index f6d28d6c1..4db1683d0 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -18,6 +18,7 @@ set(servatrice_SOURCES src/settingscache.cpp src/isl_interface.cpp src/signalhandler.cpp + src/trusted_sources.cpp ${VERSION_STRING_CPP} src/smtpclient.cpp src/smtp/qxthmac.cpp diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 68478810b..63f8864ba 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -465,7 +465,8 @@ legacy_backup_ttl_days=180 ; You may want to allow an unlimited number of users from a trusted source. This setting can contain a ; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the -; IP addresses listed (ignoring the max_users_per_address). Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74" +; IP addresses listed (ignoring the max_users_per_address). Entries must be exact IP addresses; hostnames, prefixes +; and subnets are ignored. Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74" trusted_sources="127.0.0.1,::1" ; Servatrice can avoid users from flooding rooms with large number of messages in an interval of time. diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index a306de523..77ddff1fc 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -33,6 +33,7 @@ #include "serversocketinterface.h" #include "settingscache.h" #include "smtpclient.h" +#include "trusted_sources.h" #include #include @@ -324,6 +325,14 @@ bool Servatrice::initServer() qDebug() << "Maximum websocket user limit:" << getMaxWebSocketUserLimit(); } + // trusted_sources entries are matched as exact addresses; older configs may hold hostnames, + // prefixes or subnets that substring matching used to accept and that now never match. + for (const QString &entry : getTrustedSources().split(',', Qt::SkipEmptyParts)) { + if (QHostAddress(entry.trimmed()).isNull()) { + qWarning() << "security/trusted_sources entry is not an IP address and will be ignored:" << entry.trimmed(); + } + } + qDebug() << "Accept registered users only:" << getRegOnlyServerEnabled(); qDebug() << "Registration enabled:" << getRegistrationEnabled(); if (getRegistrationEnabled()) { @@ -1324,6 +1333,11 @@ int Servatrice::getMaxUsersPerAddress() const return settingsCache->value("security/max_users_per_address", 4).toInt(); } +QString Servatrice::getTrustedSources() const +{ + return settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); +} + int Servatrice::getMessageCountingInterval() const { return settingsCache->value("security/message_counting_interval", 10).toInt(); @@ -1538,8 +1552,7 @@ bool Servatrice::isRateLimitExempt(const QString &ipAddress) const // Mirrors the max_users_per_address bypass in initSession: trusted sources // (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket // client shares one address) are not subject to per-address limits. - const QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); - return trustedSources.contains(ipAddress, Qt::CaseInsensitive); + return isTrustedSource(getTrustedSources(), ipAddress); } void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName) diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 119adc8b2..3dba23234 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -321,6 +321,8 @@ public: int getDeckShareMaxDecksPerShare() const; int getDeckShareMaxSharesPerDay() const; int getMaxUsersPerAddress() const; + /// Raw security/trusted_sources list; match addresses against it with isTrustedSource(). + QString getTrustedSources() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; int getMaxMessageSizePerInterval() const override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index b24126d93..f8b84b6c8 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -42,6 +42,7 @@ #include "server_logger.h" #include "server_protocolhandler.h" #include "settingscache.h" +#include "trusted_sources.h" #include "version_string.h" #include @@ -220,8 +221,7 @@ bool AbstractServerSocketInterface::initSession() delete identSe; // allow unlimited number of connections from the trusted sources - QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); - if (trustedSources.contains(getAddress(), Qt::CaseInsensitive)) { + if (isTrustedSource(servatrice->getTrustedSources(), getPeerAddress())) { return true; } @@ -1908,9 +1908,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com if (amountRemove != 0) { removeSaidMessages(userName, amountRemove); } - QString trustedSources = settingsCache->value("server/trusted_sources", "127.0.0.1,::1").toString(); int minutes = cmd.minutes(); - if (trustedSources.contains(address, Qt::CaseInsensitive)) { + if (isTrustedSource(servatrice->getTrustedSources(), address)) { address = ""; } @@ -4560,8 +4559,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) if (websocketIPHeader.length() > 0 && socket->request().hasRawHeader(websocketIPHeader)) { // Only trust the forwarded header if the connection itself comes from a trusted proxy, // otherwise a client could spoof its address to bypass bans and rate limits. - QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); - if (trustedSources.contains(socket->peerAddress().toString(), Qt::CaseInsensitive)) { + if (isTrustedSource(servatrice->getTrustedSources(), socket->peerAddress())) { QString header(socket->request().rawHeader(websocketIPHeader)); QHostAddress parsed(header); if (!parsed.isNull()) { diff --git a/servatrice/src/trusted_sources.cpp b/servatrice/src/trusted_sources.cpp new file mode 100644 index 000000000..327491936 --- /dev/null +++ b/servatrice/src/trusted_sources.cpp @@ -0,0 +1,23 @@ +#include "trusted_sources.h" + +#include + +bool isTrustedSource(const QString &trustedSources, const QHostAddress &address) +{ + if (address.isNull()) { + return false; + } + const QStringList entries = trustedSources.split(',', Qt::SkipEmptyParts); + for (const QString &entry : entries) { + const QHostAddress trusted(entry.trimmed()); + if (!trusted.isNull() && trusted.isEqual(address, QHostAddress::ConvertV4MappedToIPv4)) { + return true; + } + } + return false; +} + +bool isTrustedSource(const QString &trustedSources, const QString &address) +{ + return isTrustedSource(trustedSources, QHostAddress(address.trimmed())); +} diff --git a/servatrice/src/trusted_sources.h b/servatrice/src/trusted_sources.h new file mode 100644 index 000000000..3aea55e41 --- /dev/null +++ b/servatrice/src/trusted_sources.h @@ -0,0 +1,20 @@ +#ifndef TRUSTED_SOURCES_H +#define TRUSTED_SOURCES_H + +#include +#include + +/** + * @brief True if @p address is listed in the comma-separated @p trustedSources (security/trusted_sources). + * + * Entries are parsed as addresses and compared exactly, never as substrings, so + * 27.0.0.1 does not match 127.0.0.1. IPv4-mapped IPv6 addresses compare equal to + * their IPv4 form: a dual-stack listener reports a local proxy as ::ffff:127.0.0.1, + * which matches a 127.0.0.1 entry. Malformed entries and null addresses never match. + */ +bool isTrustedSource(const QString &trustedSources, const QHostAddress &address); + +/** @brief Overload for an address in string form; an unparsable string never matches. */ +bool isTrustedSource(const QString &trustedSources, const QString &address); + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b58942f3f..dfc2cb3f3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -208,6 +208,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + trusted_sources_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/trusted_sources.cpp + trusted_sources_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME server_card_counter_test diff --git a/tests/trusted_sources_test.cpp b/tests/trusted_sources_test.cpp new file mode 100644 index 000000000..a16b21f2f --- /dev/null +++ b/tests/trusted_sources_test.cpp @@ -0,0 +1,58 @@ +#include "trusted_sources.h" + +#include "gtest/gtest.h" +#include + +namespace +{ + +const QString kDefault = QStringLiteral("127.0.0.1,::1"); + +TEST(TrustedSourcesTest, MatchesListedAddresses) +{ + ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("127.0.0.1"))); + ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("::1"))); + ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress("10.0.0.1"))); +} + +TEST(TrustedSourcesTest, MatchesIpv4MappedPeerAgainstPlainIpv4Entry) +{ + // A dual-stack listener reports a local IPv4 proxy as ::ffff:127.0.0.1; + // it must still match the default 127.0.0.1 entry. + ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("::ffff:127.0.0.1"))); +} + +TEST(TrustedSourcesTest, MatchesPlainIpv4PeerAgainstMappedEntry) +{ + ASSERT_TRUE(isTrustedSource("::ffff:192.0.2.7", QHostAddress("192.0.2.7"))); +} + +TEST(TrustedSourcesTest, DoesNotMatchSubstrings) +{ + // Substring matching would let 27.0.0.1 or 0.0.1 pass as 127.0.0.1. + ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress("27.0.0.1"))); + ASSERT_FALSE(isTrustedSource("192.0.2.10", QHostAddress("192.0.2.1"))); + ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress(":"))); +} + +TEST(TrustedSourcesTest, ToleratesWhitespaceAndCase) +{ + ASSERT_TRUE(isTrustedSource(" 192.0.2.7 , 2001:DB8::1 ", QHostAddress("2001:db8::1"))); + ASSERT_TRUE(isTrustedSource(" 192.0.2.7 , 2001:DB8::1 ", QHostAddress("192.0.2.7"))); +} + +TEST(TrustedSourcesTest, IgnoresMalformedEntriesAndNullAddresses) +{ + ASSERT_FALSE(isTrustedSource("not-an-ip,,", QHostAddress("127.0.0.1"))); + ASSERT_FALSE(isTrustedSource("", QHostAddress("127.0.0.1"))); + ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress())); +} + +TEST(TrustedSourcesTest, StringOverloadParsesAddress) +{ + ASSERT_TRUE(isTrustedSource(kDefault, QStringLiteral("::ffff:127.0.0.1"))); + ASSERT_FALSE(isTrustedSource(kDefault, QStringLiteral("27.0.0.1"))); + ASSERT_FALSE(isTrustedSource(kDefault, QString())); +} + +} // namespace From ea6af0048cbd6a8f07c1d68c271d3d0ebec593cd Mon Sep 17 00:00:00 2001 From: Zach H Date: Sun, 18 Oct 2026 16:53:55 -0400 Subject: [PATCH 082/247] [Server] Parse the rightmost address from forwarded header lists (#7586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Forwarded-For carries a comma-separated list, and constructing a QHostAddress from the raw header value returns a null address for it, so a configured X-Forwarded-For or a multi-hop header never resolved and websocket clients stayed on the proxy's address. - Add parseForwardedAddress, returning the rightmost usable entry of a forwarded header list - Use it for the websocket handshake instead of parsing the raw value - Exempt loopback from per-address auth rate limits so a same-host proxy with a customized trusted_sources list cannot lock every user out - Add forwarded_address_test Co-authored-by: Lukas Brübach --- servatrice/CMakeLists.txt | 1 + servatrice/servatrice.ini.example | 8 +++-- servatrice/src/forwarded_address.cpp | 21 ++++++++++++++ servatrice/src/forwarded_address.h | 18 ++++++++++++ servatrice/src/servatrice.cpp | 7 +++-- servatrice/src/serversocketinterface.cpp | 4 +-- tests/CMakeLists.txt | 11 +++++++ tests/forwarded_address_test.cpp | 37 ++++++++++++++++++++++++ 8 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 servatrice/src/forwarded_address.cpp create mode 100644 servatrice/src/forwarded_address.h create mode 100644 tests/forwarded_address_test.cpp diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 4db1683d0..255469934 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -7,6 +7,7 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${ set(servatrice_SOURCES src/email_parser.cpp src/event_loop_watchdog.cpp + src/forwarded_address.cpp src/main.cpp src/metrics_registry.cpp src/ratelimiter.cpp diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 63f8864ba..021f5157f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -42,9 +42,11 @@ websocket_host=any websocket_port=4748 ; If websockets are served through a reverse proxy (e.g. nginx), the real client address can be -; read from an HTTP header such as X-Forwarded-For. The header is ONLY trusted when the websocket -; connection itself originates from an address listed in security/trusted_sources (default 127.0.0.1,::1), -; so that clients cannot spoof their address to bypass bans or rate limits. +; read from an HTTP header such as X-Real-IP or X-Forwarded-For. A comma-separated X-Forwarded-For +; list resolves to its rightmost entry, which is the one the proxy appended. The header is ONLY +; trusted when the websocket connection itself originates from an address listed in +; security/trusted_sources (default 127.0.0.1,::1), so that clients cannot spoof their address to +; bypass bans or rate limits. web_socket_ip_header= ; When database is enabled, servatrice writes the server status in the "update" database table; this diff --git a/servatrice/src/forwarded_address.cpp b/servatrice/src/forwarded_address.cpp new file mode 100644 index 000000000..8d5d3f4c1 --- /dev/null +++ b/servatrice/src/forwarded_address.cpp @@ -0,0 +1,21 @@ +#include "forwarded_address.h" + +#include + +QHostAddress parseForwardedAddress(const QByteArray &headerValue) +{ + const QList entries = headerValue.split(','); + for (auto it = entries.crbegin(); it != entries.crend(); ++it) { + const QByteArray entry = it->trimmed(); + if (entry.isEmpty()) { + continue; + } + + const QHostAddress parsed(QString::fromLatin1(entry)); + if (!parsed.isNull()) { + return parsed; + } + } + + return {}; +} diff --git a/servatrice/src/forwarded_address.h b/servatrice/src/forwarded_address.h new file mode 100644 index 000000000..d77e041bd --- /dev/null +++ b/servatrice/src/forwarded_address.h @@ -0,0 +1,18 @@ +#ifndef FORWARDED_ADDRESS_H +#define FORWARDED_ADDRESS_H + +#include +#include + +/** + * @brief Parses the real client address out of a forwarded header value (X-Real-IP, X-Forwarded-For). + * + * A comma-separated list resolves to its rightmost usable entry. That entry was appended by the + * proxy closest to the server and is the only one a direct client cannot choose, since the earlier + * entries are passed through verbatim from whatever the client sent. + * + * @return the parsed address, or a null QHostAddress when the value holds none. + */ +QHostAddress parseForwardedAddress(const QByteArray &headerValue); + +#endif diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 77ddff1fc..b6eaf11f4 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -1551,8 +1551,11 @@ bool Servatrice::isRateLimitExempt(const QString &ipAddress) const { // Mirrors the max_users_per_address bypass in initSession: trusted sources // (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket - // client shares one address) are not subject to per-address limits. - return isTrustedSource(getTrustedSources(), ipAddress); + // client shares one address) are not subject to per-address limits. Loopback + // is always exempt, so a same-host proxy whose forwarded address could not be + // resolved cannot lock every user out together when trusted_sources is + // customized. + return QHostAddress(ipAddress).isLoopback() || isTrustedSource(getTrustedSources(), ipAddress); } void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName) diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index f8b84b6c8..890a93fbc 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -22,6 +22,7 @@ #include "deck_tag_serialization.h" #include "email_parser.h" +#include "forwarded_address.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/developer_commands.pb.h" #include "libcockatrice/protocol/pb/moderator_commands.pb.h" @@ -4560,8 +4561,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) // Only trust the forwarded header if the connection itself comes from a trusted proxy, // otherwise a client could spoof its address to bypass bans and rate limits. if (isTrustedSource(servatrice->getTrustedSources(), socket->peerAddress())) { - QString header(socket->request().rawHeader(websocketIPHeader)); - QHostAddress parsed(header); + const QHostAddress parsed = parseForwardedAddress(socket->request().rawHeader(websocketIPHeader)); if (!parsed.isNull()) { address = parsed; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dfc2cb3f3..8811b8623 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -208,6 +208,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + forwarded_address_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/forwarded_address.cpp + forwarded_address_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME trusted_sources_test diff --git a/tests/forwarded_address_test.cpp b/tests/forwarded_address_test.cpp new file mode 100644 index 000000000..3ae2bc471 --- /dev/null +++ b/tests/forwarded_address_test.cpp @@ -0,0 +1,37 @@ +#include "forwarded_address.h" + +#include "gtest/gtest.h" + +namespace +{ + +TEST(ForwardedAddressTest, ParsesSingleAddress) +{ + EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("203.0.113.7")); + EXPECT_EQ(QHostAddress("::1"), parseForwardedAddress("::1")); + EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress(" 203.0.113.7 ")); +} + +TEST(ForwardedAddressTest, UsesRightmostEntryOfAList) +{ + // nginx's $proxy_add_x_forwarded_for appends its own view of the client to + // whatever the client sent, so only the trailing entry is trustworthy. + EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("198.51.100.9, 203.0.113.7")); + EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("spoofed.invalid, 203.0.113.7")); + EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("203.0.113.7, ")); +} + +TEST(ForwardedAddressTest, KeepsIpv4MappedEntries) +{ + EXPECT_EQ(QHostAddress("::ffff:203.0.113.7"), parseForwardedAddress("::ffff:203.0.113.7")); +} + +TEST(ForwardedAddressTest, RejectsValuesWithoutAnAddress) +{ + EXPECT_TRUE(parseForwardedAddress("").isNull()); + EXPECT_TRUE(parseForwardedAddress(",").isNull()); + EXPECT_TRUE(parseForwardedAddress("unknown").isNull()); + EXPECT_TRUE(parseForwardedAddress("garbage, more garbage").isNull()); +} + +} // namespace From 6385f3fab38c910917b5d03f23e25f69f285bbfd Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:57:03 +0200 Subject: [PATCH 083/247] [CrashReporting] Mock crash-report dialog and reporting preference (#7521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CrashReporting] Mock dialog, sentry skeleton and CI prerequisites Mock: DlgCrashReport preview dialog (no account needed, redacted log preview), CrashReportingMode Ask/Always/Never setting in DebugSettings, --simulate-crash flag and Help-menu preview entry. Send writes a local mock JSON only. Vendor sentry-native 0.17.3 via FetchContent behind WITH_CRASH_REPORTING (breakpad everywhere, backend none under sanitizers) plus a CrashReporter facade with early init and post-loop shutdown. DSN is a placeholder: empty CMake default with COCKATRICE_SENTRY_DSN env override, so SDK init is skipped and mock behavior is unchanged until a real DSN is configured. CI prerequisites riding here so every layer below builds green: libcurl dev plus CA bundles in the Linux images and CodeQL env, sentry Qt integration off (its own find_package cannot see the macOS thin Qt layout), and the StrFunc Using construction makensis requires. Tests: ctest green, format clean, code plus UI gates PASS. * [CrashReporting] Forward mode changes to SDK and fix Linux CI opt-in Connects PersonalSettings::crashReportingModeChanged to giveConsent()/revokeConsent() so switching from AlwaysSend to NeverSend (or Ask) mid-session reaches the SDK instead of leaving consent given until restart. Forwards WITH_CRASH_REPORTING into the docker container the way CCACHE_DIR is, so the Linux package leg actually builds with crash reporting ON instead of silently building OFF. * [CrashReporting] Give crash-report logging its own category per review * [CrashReporting] Drop unused DebugSettings constructor visibility per review * [CrashReporting] Document reporting modes on the enum per review * [CrashReporting] Match the dump scan to the writer's temp fallback Review nit: when the handler's SHGetKnownFolderPath lookup fails it writes to /../.., but the startup scan looked in QDir::tempPath(), so a dump produced through the fallback would never be found and its crash would never offer to be reported. Walk up the same two levels in the scan. The path is Windows-only (#ifdef Q_OS_WIN); the statements were compiled and run standalone against Qt 6 to confirm the API use. * [CrashReporting] Complete the dump-scan fallback path The previous commit walked the scan's fallback up two levels but dropped the CrashDumps/Cockatrice suffix that the writer appends unconditionally, so a dump written through the fallback was still invisible to the scan. Evaluate the writer's expression verbatim rather than mirroring it with QDir: cdUp() is existence-checked and silently leaves the path alone when the directory is missing, parent_path() is not, and QDir::absoluteFilePath() joins the shared suffix without risking a doubled separator when the walk lands on a root. Verified by compiling the block verbatim against Qt 6 with -Wextra -Werror and comparing it with the writer's join for both the normal and the fallback case. Also record that %LOCALAPPDATA% is the scan's stand-in for FOLDERID_LocalAppData and why the two normally agree. --------- Co-authored-by: Lukas Brübach --- .ci/Debian12/Dockerfile | 2 + .ci/Debian13/Dockerfile | 1 + .ci/Fedora43/Dockerfile | 2 + .ci/Fedora44/Dockerfile | 2 + .ci/Ubuntu24.04/Dockerfile | 2 + .ci/Ubuntu26.04/Dockerfile | 1 + .ci/compile.sh | 6 + .ci/docker.sh | 6 + .github/workflows/codeql.yml | 2 + .github/workflows/desktop-build.yml | 3 + CMakeLists.txt | 1 + cmake/CrashReporting.cmake | 124 ++++++++++++++ cockatrice/CMakeLists.txt | 4 + .../src/client/crash/crash_reporter.cpp | 119 ++++++++++++++ cockatrice/src/client/crash/crash_reporter.h | 66 ++++++++ cockatrice/src/interface/logger.cpp | 144 ++++++++++++++++- cockatrice/src/interface/logger.h | 12 +- .../widgets/dialogs/dlg_crash_report.cpp | 152 ++++++++++++++++++ .../widgets/dialogs/dlg_crash_report.h | 51 ++++++ .../settings_page/general_settings_page.cpp | 30 +++- .../settings_page/general_settings_page.h | 4 + cockatrice/src/main.cpp | 96 ++++++++++- .../libcockatrice/settings/debug_settings.cpp | 2 +- .../settings/personal_settings.cpp | 21 +++ .../settings/personal_settings.h | 11 ++ tests/CMakeLists.txt | 15 ++ tests/crash_reporter_test.cpp | 87 ++++++++++ tests/settings/settings_defaults_test.cpp | 27 ++++ 28 files changed, 988 insertions(+), 5 deletions(-) create mode 100644 cmake/CrashReporting.cmake create mode 100644 cockatrice/src/client/crash/crash_reporter.cpp create mode 100644 cockatrice/src/client/crash/crash_reporter.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h create mode 100644 tests/crash_reporter_test.cpp diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 14c6f018c..4da910dc0 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -3,11 +3,13 @@ FROM debian:12 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ + ca-certificates \ ccache \ cmake \ file \ g++ \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index b597307c5..94f711398 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && \ file \ g++ \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 463da5a51..8aa345651 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -1,10 +1,12 @@ FROM fedora:43 RUN dnf install -y \ + ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + libcurl-devel \ mariadb-devel \ ninja-build \ openssl-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index 62238e760..15204fa1a 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -1,10 +1,12 @@ FROM fedora:44 RUN dnf install -y \ + ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + libcurl-devel \ mariadb-devel \ ninja-build \ openssl-devel \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index b9fb2b701..43497dd3b 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -3,11 +3,13 @@ FROM ubuntu:24.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ + ca-certificates \ ccache \ cmake \ file \ g++ \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index cc6528858..4dc861ae9 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -10,6 +10,7 @@ RUN apt-get update && \ g++ \ gcovr \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/compile.sh b/.ci/compile.sh index 6789b9817..2aeeaa6ec 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -200,6 +200,12 @@ if [[ $USE_VCPKG ]]; then flags+=("-DUSE_VCPKG=1") flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") fi +if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then + # Release CI opts into the sentry-native SDK (default is OFF so from-source, distro and + # offline package builds never fetch it). Debug builds stay SDK-free so crashes from + # developer builds never reach Sentry. + flags+=("-DWITH_CRASH_REPORTING=ON") +fi # Add cmake --build flags buildflags=(--config "$BUILDTYPE") diff --git a/.ci/docker.sh b/.ci/docker.sh index 412ad6a1d..64ea6baa7 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -175,6 +175,12 @@ function RUN () args+=(--mount "type=bind,source=$GITHUB_OUTPUT,target=/gh_output") args+=(--env "GITHUB_OUTPUT=/gh_output") fi + # The container does not inherit host env vars: without this, WITH_CRASH_REPORTING=ON set in + # the workflow never reaches compile.sh inside the container, so the Linux packages silently + # build with crash reporting OFF while the libcurl packages in the Dockerfiles go unused. + if [[ ${WITH_CRASH_REPORTING:-} ]]; then + args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") + fi # shellcheck disable=2086 docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" return $? diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 75fbc59f1..75494dc3e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -60,8 +60,10 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ + ca-certificates \ cmake \ g++ \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 0b9502fe3..2215ac2d2 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -239,6 +239,7 @@ jobs: SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}' package: '${{ matrix.package }}' server_only: '${{ matrix.server_only }}' + WITH_CRASH_REPORTING: 'ON' run: | source .ci/docker.sh args=() @@ -558,6 +559,8 @@ jobs: VCPKG_DISABLE_METRICS: 1 VCPKG_FEATURE_FLAGS: dependencygraph VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }} + # Release legs compile the sentry-native SDK; Debug legs stay SDK-free. + WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }} run: .ci/compile.sh --server --test --vcpkg # CPack reports a failed makensis run as "Problem running NSIS command" diff --git a/CMakeLists.txt b/CMakeLists.txt index 87f1c746a..b294f27ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -220,6 +220,7 @@ endif() # when ENABLE_SANITIZERS or ENABLE_COVERAGE is on. include(Sanitizers) include(Coverage) +include(CrashReporting) # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") diff --git a/cmake/CrashReporting.cmake b/cmake/CrashReporting.cmake new file mode 100644 index 000000000..a728e25c2 --- /dev/null +++ b/cmake/CrashReporting.cmake @@ -0,0 +1,124 @@ +# Crash reporting via sentry-native (Sentry protocol, MIT). +# Included once from the root CMakeLists.txt, right after the sanitizer/coverage helpers. +# +# Inputs: +# WITH_CRASH_REPORTING -- build the client with the sentry-native SDK (default OFF, our release +# CI passes ON; packagers and from-source builds stay SDK-free until this +# ships something real, so offline package builds never fetch the SDK) +# COCKATRICE_SENTRY_DSN -- Sentry DSN baked into the build (default empty = SDK init skipped at runtime) +# COCKATRICE_SENTRY_TAG -- sentry-native release tag to vendor via FetchContent +# ENABLE_SANITIZERS -- forces the sentry backend to "none" (crashpad vs ASan conflict) +# Defines: cockatrice_target_enable_crash_reporting() + +option(WITH_CRASH_REPORTING "Build with crash reporting via sentry-native" OFF) +set(COCKATRICE_SENTRY_DSN + "" + CACHE STRING "Sentry DSN baked into the client; empty disables crash-report upload at runtime" +) +# Pinned stable release; tag verified at https://github.com/getsentry/sentry-native/tags. +set(COCKATRICE_SENTRY_TAG + "0.17.3" + CACHE STRING "sentry-native release tag to vendor via FetchContent" +) + +# Wire the SDK (or the no-op fallback) into a client target. Always callable: with the +# option OFF the compile definitions below make crash_reporter.cpp compile to no-ops. +function(cockatrice_target_enable_crash_reporting _target) + if(WITH_CRASH_REPORTING AND TARGET sentry::sentry) + target_link_libraries(${_target} PRIVATE sentry::sentry) + target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=1) + else() + target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=0) + endif() + # Always baked in (possibly empty): main() prefers the COCKATRICE_SENTRY_DSN env var at runtime. + target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_DSN=\"${COCKATRICE_SENTRY_DSN}\"") +endfunction() + +if(WITH_CRASH_REPORTING AND WITH_CLIENT) + include(FetchContent) + + if(ENABLE_SANITIZERS) + # The crash handler cannot run under ASan/UBSan, so there is no backend to capture with. + set(_sentry_backend "none") + elseif(WIN32 OR APPLE) + # Out-of-process handler; shipping the handler binary is a later increment, which unifies + # every OS on breakpad (in-process, no extra binary to ship). + set(_sentry_backend "crashpad") + else() + # In-process, no extra binary to ship. + set(_sentry_backend "breakpad") + endif() + # FORCE so toggling ENABLE_SANITIZERS on a reused build dir cannot leave a stale backend behind. + set(SENTRY_BACKEND + "${_sentry_backend}" + CACHE STRING "Crash handler backend for sentry-native" FORCE + ) + unset(_sentry_backend) + + # No Qt integration: it runs find_package(Qt6) inside the vendored build, which + # cannot see our thin/aqt Qt layouts (macOS CI) under foreign toolchains, and its + # breadcrumbs could carry unredacted log lines into native crash events that bypass + # before_send. Our attached redacted log preview already carries the log context. + set(SENTRY_INTEGRATION_QT + OFF + CACHE BOOL "Qt log-message breadcrumbs for sentry-native (off: discovery + secret surface)" FORCE + ) + # The transport stays at the SDK default (curl on Apple/Linux, winhttp on Windows). + set(SENTRY_BUILD_TESTS + OFF + CACHE BOOL "Build sentry-native tests" FORCE + ) + set(SENTRY_BUILD_EXAMPLES + OFF + CACHE BOOL "Build sentry-native examples" FORCE + ) + + fetchcontent_declare( + sentry + GIT_REPOSITORY https://github.com/getsentry/sentry-native.git + GIT_TAG ${COCKATRICE_SENTRY_TAG} + ) + fetchcontent_makeavailable(sentry) + + # Vendored sentry-native (and its breakpad/crashpad trees) warn under our -Wall -Wextra + # (e.g. breakpad's -Wmissing-field-initializers), and WARNING_AS_ERROR would fail the build + # on code we cannot fix. Those findings are not actionable for us, so opt the whole vendored + # tree out of warnings-as-errors while keeping the warnings themselves visible; our own + # targets keep -Werror. Interface libraries compile nothing, so they are skipped outright: + # an INTERFACE -Wno-error would propagate to our consumers and weaken their build. + function(_cockatrice_sentry_opt_out_of_werror _dir) + get_property( + _sentry_targets + DIRECTORY "${_dir}" + PROPERTY BUILDSYSTEM_TARGETS + ) + foreach(_sentry_target IN LISTS _sentry_targets) + get_target_property(_sentry_type ${_sentry_target} TYPE) + if(_sentry_type STREQUAL "INTERFACE_LIBRARY") + continue() + elseif(_sentry_type MATCHES "LIBRARY|EXECUTABLE") + get_target_property(_sentry_imported ${_sentry_target} IMPORTED) + if(NOT _sentry_imported) + target_compile_options(${_sentry_target} PRIVATE -Wno-error) + endif() + endif() + endforeach() + get_property( + _sentry_subdirs + DIRECTORY "${_dir}" + PROPERTY SUBDIRECTORIES + ) + foreach(_sentry_subdir IN LISTS _sentry_subdirs) + _cockatrice_sentry_opt_out_of_werror("${_sentry_subdir}") + endforeach() + endfunction() + if(NOT MSVC) + _cockatrice_sentry_opt_out_of_werror("${sentry_SOURCE_DIR}") + endif() + + message(STATUS "Crash reporting: sentry-native ${COCKATRICE_SENTRY_TAG} (backend: ${SENTRY_BACKEND})") +else() + message(STATUS "Crash reporting: SDK not vendored " + "(WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}, WITH_CLIENT=${WITH_CLIENT})" + ) +endif() diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 5baf10b1e..bec858dcb 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -15,6 +15,7 @@ set(cockatrice_SOURCES src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/release_channel.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp + src/client/crash/crash_reporter.cpp src/client/latency_graph_widget.cpp src/client/latency_status_widget.cpp src/client/lag_monitor.cpp @@ -30,6 +31,7 @@ set(cockatrice_SOURCES 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 + src/interface/widgets/dialogs/dlg_crash_report.cpp src/interface/widgets/dialogs/dlg_default_tags_editor.cpp src/interface/widgets/dialogs/dlg_edit_avatar.cpp src/interface/widgets/dialogs/dlg_edit_password.cpp @@ -578,6 +580,8 @@ target_link_libraries( ${COCKATRICE_QT_MODULES} ) +cockatrice_target_enable_crash_reporting(cockatrice) + if(UNIX) if(APPLE) set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}") diff --git a/cockatrice/src/client/crash/crash_reporter.cpp b/cockatrice/src/client/crash/crash_reporter.cpp new file mode 100644 index 000000000..fe1678b11 --- /dev/null +++ b/cockatrice/src/client/crash/crash_reporter.cpp @@ -0,0 +1,119 @@ +#include "crash_reporter.h" + +#if COCKATRICE_WITH_CRASH_REPORTING +#include +#endif + +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(CrashReportLog, "crash_report"); + +bool CrashReporter::isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode) +{ + return !dsn.isEmpty() && consentMode != PersonalSettings::CrashReportingMode::NeverSend; +} + +void CrashReporter::init(const QString &dsn, + const QString &release, + const QString &databasePath, + PersonalSettings::CrashReportingMode consentMode) +{ + shutdown(); + sentryDatabasePath = databasePath; + if (!isReportingEnabled(dsn, consentMode)) { + return; + } + // Only created when the SDK will actually run: no-DSN launches stay side-effect free. +#if COCKATRICE_WITH_CRASH_REPORTING + QDir().mkpath(sentryDatabasePath); + if (consentMode != PersonalSettings::CrashReportingMode::AlwaysSend) { + // sentry-native persists consent to /user-consent and reloads it in sentry_init + // before old runs are processed, so a stale "given" from an earlier AlwaysSend run (or an + // earlier Send in Ask mode) would upload the next crash before the dialog asks. Our + // CrashReportingMode is the source of truth, so clear the persisted state first; asking + // afterwards re-establishes it via giveConsent()/revokeConsent(). + QFile::remove(sentryDatabasePath + QStringLiteral("/user-consent")); + } + sentry_options_t *options = sentry_options_new(); + sentry_options_set_dsn(options, dsn.toUtf8().constData()); + sentry_options_set_release(options, release.toUtf8().constData()); + sentry_options_set_database_path(options, sentryDatabasePath.toUtf8().constData()); + // Consent-gated uploads: crashes are captured into the local database but + // nothing leaves the machine until giveConsent(). + sentry_options_set_require_user_consent(options, 1); + ready = (sentry_init(options) == 0); +#else + Q_UNUSED(release); + ready = false; +#endif +} + +bool CrashReporter::isReady() const +{ + return ready; +} + +void CrashReporter::shutdown() +{ +#if COCKATRICE_WITH_CRASH_REPORTING + if (ready) { + sentry_close(); + } +#endif + ready = false; +} + +void CrashReporter::giveConsent() +{ +#if COCKATRICE_WITH_CRASH_REPORTING + if (ready) { + sentry_user_consent_give(); + } +#endif +} + +void CrashReporter::revokeConsent() +{ +#if COCKATRICE_WITH_CRASH_REPORTING + if (ready) { + sentry_user_consent_revoke(); + } +#endif +} + +QString CrashReporter::sanitizeLogLine(const QString &line) +{ + static const auto colorEscapeCodePattern = QRegularExpression(QStringLiteral("\033\\[[0-9;]*m")); + + QString sanitized = line; + sanitized.replace(colorEscapeCodePattern, ""); + return sanitized; +} + +QString CrashReporter::redactCockatriceUrls(const QString &line) +{ + static auto urlPattern = QRegularExpression(QStringLiteral("cockatrice://\\S+")); + + // Single pass over the original line: rescanning the replaced text would match a redacted + // secret-free URL again and loop forever. + QString redacted; + redacted.reserve(line.size()); + qsizetype pos = 0; + QRegularExpressionMatchIterator it = urlPattern.globalMatch(line); + while (it.hasNext()) { + QRegularExpressionMatch match = it.next(); + redacted.append(line.mid(pos, match.capturedStart() - pos)); + // The pattern only matches cockatrice:// URLs, so the check is folded into the parse: + // scheme://host, secrets dropped. + const QUrl parsed(match.captured(0)); + redacted.append(parsed.scheme() + QStringLiteral("://") + parsed.host()); + pos = match.capturedEnd(); + } + redacted.append(line.mid(pos)); + return redacted; +} diff --git a/cockatrice/src/client/crash/crash_reporter.h b/cockatrice/src/client/crash/crash_reporter.h new file mode 100644 index 000000000..f57ec6019 --- /dev/null +++ b/cockatrice/src/client/crash/crash_reporter.h @@ -0,0 +1,66 @@ +/** + * @file crash_reporter.h + * @ingroup Core + */ + +#ifndef CRASH_REPORTER_H +#define CRASH_REPORTER_H + +#include +#include +#include + +#ifndef COCKATRICE_WITH_CRASH_REPORTING +#define COCKATRICE_WITH_CRASH_REPORTING 0 +#endif + +#ifndef COCKATRICE_SENTRY_DSN +#define COCKATRICE_SENTRY_DSN "" +#endif + +Q_DECLARE_LOGGING_CATEGORY(CrashReportLog); + +/** + * @brief Sentry-backed crash-report facade, owned by main(). + * + * Single responsibility: init/shutdown the sentry-native SDK behind consent + * gating, plus the shared log redaction helpers. An empty DSN (or Never + * consent) skips the SDK entirely, so the mock dialog fallback behaves exactly + * as before. init() arms require-user-consent so cached crash envelopes never + * upload before the user is asked; giveConsent()/revokeConsent() (driven by + * the reporting-mode setting and the crash dialog) gate uploads, and any stale + * persisted consent from a previous AlwaysSend run is cleared before init when + * the mode is not AlwaysSend. The real send path is a later increment. All SDK + * calls are guarded so OFF builds compile to no-ops. + */ +class CrashReporter +{ +public: + CrashReporter() = default; + CrashReporter(const CrashReporter &) = delete; + CrashReporter &operator=(const CrashReporter &) = delete; + ~CrashReporter() = default; + + void init(const QString &dsn, + const QString &release, + const QString &databasePath, + PersonalSettings::CrashReportingMode consentMode); + [[nodiscard]] bool isReady() const; + void shutdown(); + void giveConsent(); + void revokeConsent(); + + /** + * @brief SDK-independent gating decision, unit-testable in OFF builds. + */ + static bool isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode); + + static QString sanitizeLogLine(const QString &line); + static QString redactCockatriceUrls(const QString &line); + +private: + bool ready = false; + QString sentryDatabasePath; +}; + +#endif diff --git a/cockatrice/src/interface/logger.cpp b/cockatrice/src/interface/logger.cpp index 87fdcbd8c..864840f4e 100644 --- a/cockatrice/src/interface/logger.cpp +++ b/cockatrice/src/interface/logger.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -18,8 +20,11 @@ #define LOGGER_MAX_ENTRIES 128 #define LOGGER_FILENAME "qdebug.txt" +#define LOGGER_SESSION_MARKER "Log session started at" +#define LOGGER_PERSISTENT_MAX_BYTES (512 * 1024) +#define LOGGER_PERSISTENT_KEEP_LINES 2000 -Logger::Logger() : logToFileEnabled(false) +Logger::Logger() : logToFileEnabled(false), persistentLogEnabled(false) { logBuffer.append(getClientVersion()); logBuffer.append(getSystemArchitecture()); @@ -35,6 +40,7 @@ Logger::Logger() : logToFileEnabled(false) Logger::~Logger() { closeLogfileSession(); + closePersistentLogSession(); logBuffer.clear(); } @@ -82,6 +88,137 @@ void Logger::closeLogfileSession() fileHandle.close(); } +void Logger::setPersistentLogPath(const QString &path) +{ + QMutexLocker locker(&mutex); + if (path.isEmpty() || (persistentLogEnabled && persistentLogPath == path)) { + return; + } + + QDir().mkpath(QFileInfo(path).absolutePath()); + persistentLogPath = path; + openPersistentLog(path); +} + +void Logger::prunePersistentLog(const QString &path) +{ + QFileInfo info(path); + if (!info.exists() || info.size() <= LOGGER_PERSISTENT_MAX_BYTES) { + return; + } + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + QList allLines; + QTextStream in(&file); + while (!in.atEnd()) { + allLines.append(in.readLine()); + } + file.close(); + + const int start = + allLines.size() > LOGGER_PERSISTENT_KEEP_LINES ? allLines.size() - LOGGER_PERSISTENT_KEEP_LINES : 0; + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + return; + } + QTextStream out(&file); + for (int i = start; i < allLines.size(); ++i) { + out << allLines.at(i) << Qt::endl; + } + file.close(); +} + +void Logger::openPersistentLog(const QString &path) +{ + prunePersistentLog(path); + + persistentFileHandle.setFileName(path); + if (!persistentFileHandle.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + qWarning() << "Logger failed to open persistent log" << path << "for writing"; + return; + } + persistentFileStream.setDevice(&persistentFileHandle); + persistentFileStream << LOGGER_SESSION_MARKER << " " << QDateTime::currentDateTime().toString() << Qt::endl; + persistentFileStream << getClientVersion() << Qt::endl; + persistentFileStream << getSystemArchitecture() << Qt::endl; + persistentFileStream << getClientInstallInfo() << Qt::endl; + persistentFileStream.flush(); + persistentLogEnabled = true; +} + +void Logger::closePersistentLogSession() +{ + if (!persistentLogEnabled) { + return; + } + + persistentLogEnabled = false; + persistentFileStream << "Log session closed at " << QDateTime::currentDateTime().toString() << Qt::endl; + persistentFileStream.flush(); + persistentFileHandle.close(); +} + +QList Logger::getPreviousSessionTail(int maxLines) const +{ + QString path; + { + QMutexLocker locker(&mutex); + path = persistentLogPath; + } + if (path.isEmpty()) { + return {}; + } + return extractPreviousSessionTail(path, maxLines); +} + +QList Logger::extractPreviousSessionTail(const QString &filePath, int maxLines) +{ + QList result; + if (maxLines <= 0) { + return result; + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return result; + } + QList allLines; + QTextStream in(&file); + while (!in.atEnd()) { + allLines.append(in.readLine()); + } + file.close(); + + QList markerIndexes; + for (int i = 0; i < allLines.size(); ++i) { + if (allLines.at(i).contains(QLatin1String(LOGGER_SESSION_MARKER))) { + markerIndexes.append(i); + } + } + + QList tail; + if (markerIndexes.isEmpty()) { + tail = allLines; + } else if (markerIndexes.size() == 1) { + // Only the current session has been recorded; there is no previous session yet. + return result; + } else { + const int previousStart = markerIndexes.at(markerIndexes.size() - 2); + const int currentStart = markerIndexes.last(); + for (int i = previousStart; i < currentStart; ++i) { + tail.append(allLines.at(i)); + } + } + + const int start = tail.size() > maxLines ? tail.size() - maxLines : 0; + for (int i = start; i < tail.size(); ++i) { + result.append(tail.at(i)); + } + return result; +} + void Logger::log(QtMsgType /* type */, const QMessageLogContext & /* ctx */, const QString &message) { QMetaObject::invokeMethod(this, "internalLog", Qt::QueuedConnection, Q_ARG(const QString &, message)); @@ -102,6 +239,11 @@ void Logger::internalLog(const QString &message) if (logToFileEnabled) { fileStream << message << Qt::endl; // Print to fileStream } + + if (persistentLogEnabled) { + persistentFileStream << message << Qt::endl; + persistentFileStream.flush(); + } } QString Logger::getSystemArchitecture() diff --git a/cockatrice/src/interface/logger.h b/cockatrice/src/interface/logger.h index 2287da804..cc7cee046 100644 --- a/cockatrice/src/interface/logger.h +++ b/cockatrice/src/interface/logger.h @@ -49,6 +49,9 @@ public: { return logBuffer; } + void setPersistentLogPath(const QString &path); + QList getPreviousSessionTail(int maxLines = 60) const; + static QList extractPreviousSessionTail(const QString &filePath, int maxLines = 60); private: Logger(); @@ -61,11 +64,18 @@ private: QTextStream fileStream; QFile fileHandle; QList logBuffer; - QMutex mutex; + mutable QMutex mutex; + QString persistentLogPath; + bool persistentLogEnabled; + QTextStream persistentFileStream; + QFile persistentFileHandle; protected: void openLogfileSession(); void closeLogfileSession(); + void openPersistentLog(const QString &path); + void prunePersistentLog(const QString &path); + void closePersistentLogSession(); protected slots: void internalLog(const QString &message); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp new file mode 100644 index 000000000..7fc69e12b --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp @@ -0,0 +1,152 @@ +#include "dlg_crash_report.h" + +#include "../../../client/crash/crash_reporter.h" +#include "../../../client/settings/cache_settings.h" +#include "../../logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent) +{ + infoLabel = new QLabel(this); + infoLabel->setWordWrap(true); + + accountLabel = new QLabel(this); + accountLabel->setWordWrap(true); + + previewArea = new QPlainTextEdit(this); + previewArea->setReadOnly(true); + previewArea->setPlainText(buildRedactedPreview()); + + rememberBox = new QCheckBox(this); + + sendButton = new QPushButton(this); + sendButton->setDefault(true); + connect(sendButton, &QPushButton::clicked, this, &DlgCrashReport::actSend); + + dontSendButton = new QPushButton(this); + dontSendButton->setAutoDefault(false); + connect(dontSendButton, &QPushButton::clicked, this, &DlgCrashReport::actDontSend); + + auto *buttonLayout = new QHBoxLayout; + buttonLayout->addWidget(rememberBox); + buttonLayout->addStretch(); + buttonLayout->addWidget(dontSendButton); + buttonLayout->addWidget(sendButton); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(infoLabel); + mainLayout->addWidget(accountLabel); + mainLayout->addWidget(previewArea); + mainLayout->addLayout(buttonLayout); + setLayout(mainLayout); + + resize(700, 500); + retranslateUi(); +} + +void DlgCrashReport::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QDialog::changeEvent(event); +} + +void DlgCrashReport::retranslateUi() +{ + setWindowTitle(tr("Send crash report")); + infoLabel->setText( + tr("Cockatrice closed unexpectedly. The preview below shows what a crash report would contain (mock).")); + accountLabel->setText(tr("No account needed to send a crash report.")); + rememberBox->setText(tr("Remember my choice")); + sendButton->setText(tr("Send")); + dontSendButton->setText(tr("Don't send")); +} + +QString DlgCrashReport::buildRedactedPreview() +{ + Logger &logger = Logger::getInstance(); + + QStringList header; + header.append(logger.getClientVersion()); + header.append(logger.getSystemArchitecture()); + header.append(logger.getSystemLocale()); + header.append(logger.getClientInstallInfo()); + header.append(QStringLiteral("---")); + + QList logLines = logger.getPreviousSessionTail(); + if (logLines.isEmpty()) { + logLines = logger.getLogBuffer(); + } + const int previewLines = 60; + int start = logLines.size() > previewLines ? logLines.size() - previewLines : 0; + + QStringList lines = header; + for (int i = start; i < logLines.size(); ++i) { + lines.append(CrashReporter::redactCockatriceUrls(CrashReporter::sanitizeLogLine(logLines.at(i)))); + } + return lines.join(QStringLiteral("\n")); +} + +QString DlgCrashReport::writeMockReport(const QString &previewText) +{ + Logger &logger = Logger::getInstance(); + + QJsonObject report; + report[QStringLiteral("mock")] = true; + report[QStringLiteral("timestamp")] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + report[QStringLiteral("clientVersion")] = logger.getClientVersion(); + report[QStringLiteral("systemArchitecture")] = logger.getSystemArchitecture(); + report[QStringLiteral("systemLocale")] = logger.getSystemLocale(); + report[QStringLiteral("installInfo")] = logger.getClientInstallInfo(); + report[QStringLiteral("logPreview")] = previewText; + + QString fileName = QStringLiteral("crash-report-mock-%1.json") + .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmss"))); + QString filePath = QDir::tempPath() + QStringLiteral("/") + fileName; + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + return QString(); + } + file.write(QJsonDocument(report).toJson()); + file.close(); + return filePath; +} + +void DlgCrashReport::actSend() +{ + if (rememberBox->isChecked()) { + SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend); + } + const QString savedPath = writeMockReport(previewArea->toPlainText()); + if (savedPath.isEmpty()) { + qCWarning(CrashReportLog) << "Could not save the mock crash report locally"; + } else { + qCInfo(CrashReportLog) << "Mock crash report saved locally to" << savedPath; + } + accept(); +} + +void DlgCrashReport::actDontSend() +{ + if (rememberBox->isChecked()) { + SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend); + } + reject(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h new file mode 100644 index 000000000..576eb3cf9 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h @@ -0,0 +1,51 @@ +/** + * @file dlg_crash_report.h + * @ingroup Dialogs + */ + +#ifndef DLG_CRASH_REPORT_H +#define DLG_CRASH_REPORT_H + +#include +#include +#include + +class QCheckBox; +class QLabel; +class QPlainTextEdit; +class QPushButton; + +/** + * @brief MOCK crash-report dialog (UI + settings only, no network). + * + * The Send path writes a redacted JSON file to the temp dir instead of + * uploading anything. Real Sentry upload comes in a later phase. + */ +class DlgCrashReport : public QDialog +{ + Q_OBJECT +public: + explicit DlgCrashReport(QWidget *parent = nullptr); + + static QString buildRedactedPreview(); + static QString writeMockReport(const QString &previewText); + +protected: + void changeEvent(QEvent *event) override; + +private: + QLabel *infoLabel; + QLabel *accountLabel; + QPlainTextEdit *previewArea; + QCheckBox *rememberBox; + QPushButton *sendButton; + QPushButton *dontSendButton; + + void retranslateUi(); + +private slots: + void actSend(); + void actDontSend(); +}; + +#endif 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 fa8938b32..a19417661 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -133,6 +133,27 @@ GeneralSettingsPage::GeneralSettingsPage() versionGroupBox = new QGroupBox; versionGroupBox->setLayout(versionGrid); + // crash reporting consent (stored in personal.ini, not debug.ini) + crashReportingBox.addItem(""); // texts set in retranslateUi + crashReportingBox.addItem(""); + crashReportingBox.addItem(""); + crashReportingBox.setCurrentIndex(static_cast(settings.personal().getCrashReportingMode())); + + connect(&crashReportingBox, qOverload(&QComboBox::currentIndexChanged), this, [](int index) { + if (index < 0 || index > static_cast(PersonalSettings::CrashReportingMode::NeverSend)) { + return; + } + SettingsCache::instance().personal().setCrashReportingMode( + static_cast(index)); + }); + + auto *crashReportingGrid = new QGridLayout; + crashReportingGrid->addWidget(&crashReportingLabel, 0, 0); + crashReportingGrid->addWidget(&crashReportingBox, 0, 1); + + crashReportingGroupBox = new QGroupBox; + crashReportingGroupBox->setLayout(crashReportingGrid); + // card database settings startupCardUpdateCheckBehaviorSelector.addItem(""); // these will be set in retranslateUI startupCardUpdateCheckBehaviorSelector.addItem(""); @@ -336,6 +357,7 @@ GeneralSettingsPage::GeneralSettingsPage() auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(languageGroupBox); mainLayout->addWidget(versionGroupBox); + mainLayout->addWidget(crashReportingGroupBox); mainLayout->addWidget(cardDatabaseGroupBox); mainLayout->addWidget(startupGroupBox); mainLayout->addWidget(pathsGroupBox); @@ -569,13 +591,19 @@ void GeneralSettingsPage::retranslateUi() tr("English and selected card language")); advertiseTranslationPageLabel.setText( QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); - versionGroupBox->setTitle(tr("Version settings")); updateReleaseChannelLabel.setText(tr("Update channel")); startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); + crashReportingGroupBox->setTitle(tr("Crash reporting")); + crashReportingLabel.setText(tr("Send crash reports:")); + crashReportingBox.setItemText(static_cast(PersonalSettings::CrashReportingMode::Ask), tr("Ask every time")); + crashReportingBox.setItemText(static_cast(PersonalSettings::CrashReportingMode::AlwaysSend), + tr("Always send")); + crashReportingBox.setItemText(static_cast(PersonalSettings::CrashReportingMode::NeverSend), tr("Never send")); + // We can't change the strings after they're put into the QComboBox, so this is our workaround int oldIndex = updateReleaseChannelBox.currentIndex(); updateReleaseChannelBox.clear(); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index fcf1c78d0..20564c968 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -49,6 +49,7 @@ private slots: private: QGroupBox *languageGroupBox; QGroupBox *versionGroupBox; + QGroupBox *crashReportingGroupBox; QGroupBox *cardDatabaseGroupBox; QGroupBox *startupGroupBox; QGroupBox *pathsGroupBox; @@ -70,6 +71,9 @@ private: QCheckBox updateNotificationCheckBox; QCheckBox newVersionOracleCheckBox; + QLabel crashReportingLabel; + QComboBox crashReportingBox; + QLabel startupCardUpdateCheckBehaviorLabel; QComboBox startupCardUpdateCheckBehaviorSelector; QLabel cardUpdateCheckIntervalLabel; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 3f63efc3b..38ec3d137 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -20,6 +20,7 @@ #include "main.h" +#include "client/crash/crash_reporter.h" #include "client/network/update/card_spoiler/spoiler_background_updater.h" #include "client/settings/cache_settings.h" #include "client/sound_engine.h" @@ -30,6 +31,7 @@ #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" +#include "interface/widgets/dialogs/dlg_crash_report.h" #include "interface/window_main.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/rng/rng_abstract.h" @@ -44,6 +46,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -259,6 +264,46 @@ int main(int argc, char *argv[]) qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true); #endif + // Crash reporting (increment 1): init the SDK early so a crash anywhere below is captured into the + // local database. Uploads stay consent-gated (require_user_consent, armed in CrashReporter::init): + // AlwaysSend gives consent here so cached envelopes flush at startup; any other mode starts + // unconsented and the dialog below drives give/revoke. An empty DSN skips the SDK entirely and + // the mock fallback further below behaves exactly as before. + CrashReporter crashReporter; + const QString crashDatabasePath = SettingsCache::instance().getSettingsPath() + QStringLiteral("sentry"); + { + QString crashDsn = QString::fromUtf8(qgetenv("COCKATRICE_SENTRY_DSN")); + if (crashDsn.isEmpty()) { + crashDsn = QString::fromLatin1(COCKATRICE_SENTRY_DSN); + } + crashReporter.init(crashDsn, QString::fromLatin1(VERSION_STRING), crashDatabasePath, + SettingsCache::instance().personal().getCrashReportingMode()); + if (crashReporter.isReady() && SettingsCache::instance().personal().getCrashReportingMode() == + PersonalSettings::CrashReportingMode::AlwaysSend) { + // Silent opt-in: cached crash envelopes flush automatically once consent is given. + crashReporter.giveConsent(); + } + // Mid-session mode changes must reach the SDK: without this, switching from AlwaysSend to + // NeverSend (or Ask) leaves consent given until restart, so a later crash in this session + // would still upload after the user opted out. crashReporter outlives main(), so capturing + // it here is safe. + QObject::connect(&SettingsCache::instance().personal(), &PersonalSettings::crashReportingModeChanged, + [&crashReporter](PersonalSettings::CrashReportingMode mode) { + if (mode == PersonalSettings::CrashReportingMode::AlwaysSend) { + crashReporter.giveConsent(); + } else { + crashReporter.revokeConsent(); + } + }); + } + + // Persistent session log: keeps this run's logs on disk so the next-startup crash dialog can + // attach the crashed session's tail (the in-memory buffer is lost on crash). Opened in Append + // mode with a per-launch session marker; read back via Logger::getPreviousSessionTail(). + const QString persistentLogPath = + SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log"); + Logger::getInstance().setPersistentLogPath(persistentLogPath); + // Translations #ifdef Q_OS_MAC translationPath = qApp->applicationDirPath() + "/../Resources/translations"; @@ -276,7 +321,9 @@ int main(int argc, char *argv[]) parser.addOptions( {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, - {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}}); + {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}, + {"simulate-crash", + QCoreApplication::translate("main", "Show the mock crash-report dialog (nothing is sent)")}}); parser.process(app); @@ -413,6 +460,51 @@ int main(int argc, char *argv[]) ui.show(); qCInfo(MainLog) << "ui.show() finished"; + // MOCK crash-report flow (UI + settings only): no sentry-native upload, no network. + // Triggered by --simulate-crash or by leftover Windows crash dumps. Handled dumps are deleted + // after Send / Don't send (or a silent NeverSend skip) so one crash never prompts twice; on + // non-Windows nothing writes cockatrice.crash.*.dmp yet, so --simulate-crash is the trigger there. + { + QString crashDumpDir; +#ifdef Q_OS_WIN + // Mirrors the writer (CockatriceUnhandledExceptionFilter): it appends CrashDumps/Cockatrice + // to %LOCALAPPDATA%, or, when its known-folder lookup fails, to the temp directory walked up + // two parent_path() steps. %LOCALAPPDATA% is this scan's stand-in for FOLDERID_LocalAppData: + // both come from the same profile, so when the lookup fails the variable is normally unset. + QString crashDumpRootPath = QString::fromLocal8Bit(qgetenv("LOCALAPPDATA")); + if (crashDumpRootPath.isEmpty()) { + // The writer's fallback expression, verbatim — QDir::cdUp() is existence-checked and + // would silently leave the path alone here, parent_path() is not. + const std::filesystem::path fallback = std::filesystem::temp_directory_path().parent_path().parent_path(); + crashDumpRootPath = QString::fromStdWString(fallback.wstring()); + } + crashDumpDir = QDir(crashDumpRootPath).absoluteFilePath(QStringLiteral("CrashDumps/Cockatrice")); +#else + crashDumpDir = QDir::tempPath(); +#endif + const QStringList crashDumps = + QDir(crashDumpDir).entryList({QStringLiteral("cockatrice.crash.*.dmp")}, QDir::Files); + if (parser.isSet("simulate-crash") || !crashDumps.isEmpty()) { + const PersonalSettings::CrashReportingMode crashMode = + SettingsCache::instance().personal().getCrashReportingMode(); + if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { + crashReporter.giveConsent(); + DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); + } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { + DlgCrashReport crashDialog(&ui); + if (crashDialog.exec() == QDialog::Accepted) { + crashReporter.giveConsent(); + } else { + crashReporter.revokeConsent(); + } + } + // NeverSend: skip silently (no dialog, nothing written or sent). + for (const QString &dump : crashDumps) { + QFile::remove(crashDumpDir + QStringLiteral("/") + dump); + } + } + } + // force shortcuts to be shown/hidden in right-click menus, regardless of system defaults qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, !SettingsCache::instance().userInterface().getShowShortcuts()); @@ -437,6 +529,8 @@ int main(int argc, char *argv[]) int ret = app.exec(); + crashReporter.shutdown(); + qCInfo(MainLog) << "Event loop finished, terminating..."; delete rng; PingPixmapGenerator::clear(); diff --git a/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp b/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp index c36e7187e..709155b1c 100644 --- a/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp @@ -34,4 +34,4 @@ int DebugSettings::getLocalGamePlayerCount() const QString DebugSettings::getDeckPathForPlayer(const QString &playerName) const { return getValue(playerName, "localgame", "deck").toString(); -} \ No newline at end of file +} diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp index 682144485..79d06ddd4 100644 --- a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp @@ -51,3 +51,24 @@ void PersonalSettings::setSeenTips(const QList &_seenTips) } setValue(QVariant::fromValue(storedTipList), "seenTips", "tipOfDay"); } + +PersonalSettings::CrashReportingMode PersonalSettings::getCrashReportingMode() const +{ + const int raw = getValue("crashReportingMode", QString(), QString(), 0).toInt(); + switch (raw) { + case 1: + return CrashReportingMode::AlwaysSend; + case 2: + return CrashReportingMode::NeverSend; + case 0: + default: + // Clamp corrupt ini values back to Ask; the QSettings key string is unchanged (no migration). + return CrashReportingMode::Ask; + } +} + +void PersonalSettings::setCrashReportingMode(CrashReportingMode _crashReportingMode) +{ + setValue(static_cast(_crashReportingMode), "crashReportingMode"); + emit crashReportingModeChanged(_crashReportingMode); +} diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.h b/libcockatrice_settings/libcockatrice/settings/personal_settings.h index 0819af41b..0cd51cdd0 100644 --- a/libcockatrice_settings/libcockatrice/settings/personal_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.h @@ -24,8 +24,19 @@ public: void setShowTipsOnStartup(bool _showTipsOnStartup); void setSeenTips(const QList &_seenTips); + enum class CrashReportingMode + { + Ask = 0, ///< Show the crash-report dialog after an unclean exit. + AlwaysSend = 1, ///< Upload crash reports without asking. + NeverSend = 2 ///< Never upload; stay silent. + }; + + [[nodiscard]] CrashReportingMode getCrashReportingMode() const; + void setCrashReportingMode(CrashReportingMode _crashReportingMode); + signals: void langChanged(); + void crashReportingModeChanged(CrashReportingMode mode); public: explicit PersonalSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8811b8623..aac4d4818 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -422,6 +422,21 @@ if(WITH_CLIENT) ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/libcockatrice_utility ) + # CrashReporter is client code; DEFINITIONS keeps the test hermetic by compiling the + # no-op path (no sentry-native headers needed) regardless of WITH_CRASH_REPORTING. + cockatrice_add_test( + NAME + crash_reporter_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/crash/crash_reporter.cpp + crash_reporter_test.cpp + LIBS + libcockatrice_settings + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + DEFINITIONS + COCKATRICE_WITH_CRASH_REPORTING=0 + ) endif() add_subdirectory(card_zone_algorithms) diff --git a/tests/crash_reporter_test.cpp b/tests/crash_reporter_test.cpp new file mode 100644 index 000000000..c52a2e495 --- /dev/null +++ b/tests/crash_reporter_test.cpp @@ -0,0 +1,87 @@ +#include "client/crash/crash_reporter.h" + +#include +#include +#include +#include + +namespace +{ + +TEST(CrashReporterTest, SanitizeLogLineStripsAnsiColorCodes) +{ + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[32mhello\033[0m")), QStringLiteral("hello")); + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[1;31mfatal\033[0m")), QStringLiteral("fatal")); + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("plain line")), QStringLiteral("plain line")); +} + +TEST(CrashReporterTest, RedactCockatriceUrlsKeepsSchemeAndHostOnly) +{ + ASSERT_EQ( + CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck?token=secret&user=me now")), + QStringLiteral("open cockatrice://opendeck now")); +} + +TEST(CrashReporterTest, RedactCockatriceUrlsIsIdempotent) +{ + // A secret-free URL must not match again after redaction (regression: this hung forever). + ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck now")), + QStringLiteral("open cockatrice://opendeck now")); +} + +TEST(CrashReporterTest, RedactCockatriceUrlsLeavesOtherTextAlone) +{ + ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("https://cockatrice.github.io/?token=secret")), + QStringLiteral("https://cockatrice.github.io/?token=secret")); + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("")), QStringLiteral("")); +} + +TEST(CrashReporterTest, InitSkippedWithoutDsn) +{ + CrashReporter reporter; + reporter.init(QString(), QStringLiteral("3.1.2"), QStringLiteral("/tmp/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::Ask); + ASSERT_FALSE(reporter.isReady()); + reporter.shutdown(); + ASSERT_FALSE(reporter.isReady()); +} + +TEST(CrashReporterTest, InitSkippedWhenNeverSend) +{ + CrashReporter reporter; + reporter.init(QStringLiteral("https://key@sentry.io/1"), QStringLiteral("3.1.2"), + QStringLiteral("/tmp/cockatrice-crash-test"), PersonalSettings::CrashReportingMode::NeverSend); + ASSERT_FALSE(reporter.isReady()); + reporter.shutdown(); +} + +// The init() tests above compile to no-ops when the SDK is off, so they pass +// regardless of the gating; the gating decision itself is covered here, in both +// build modes, through the SDK-independent helper init() consults. +TEST(CrashReporterTest, ReportingEnabledGating) +{ + using Mode = PersonalSettings::CrashReportingMode; + const QString dsn = QStringLiteral("https://key@sentry.io/1"); + ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::Ask)); + ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::AlwaysSend)); + ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::NeverSend)); + ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::Ask)); + ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::AlwaysSend)); + ASSERT_FALSE(CrashReporter::isReportingEnabled(dsn, Mode::NeverSend)); +} + +TEST(CrashReporterTest, ShutdownWithoutInitIsSafe) +{ + CrashReporter reporter; + ASSERT_FALSE(reporter.isReady()); + reporter.shutdown(); + ASSERT_FALSE(reporter.isReady()); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 0d4cdbafe..aea7f1d54 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -367,6 +368,32 @@ TEST_F(SettingsDefaultsTest, Personal_ShowTipsOnStartup_Default) ASSERT_EQ(s.getShowTipsOnStartup(), true); } +TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_Default) +{ + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask); +} + +TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_SetAndGet) +{ + PersonalSettings s(settingsPath, nullptr); + s.setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::AlwaysSend); + s.setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::NeverSend); + s.setCrashReportingMode(PersonalSettings::CrashReportingMode::Ask); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask); +} + +TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_ClampsCorruptValue) +{ + QSettings raw(settingsPath + "personal.ini", QSettings::IniFormat); + raw.setValue("personal/crashReportingMode", 99); + raw.sync(); + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask); +} + // --- DownloadSettings --- TEST_F(SettingsDefaultsTest, Download_PicDownload_Default) From ad0fd2d4b04662c259030d275f92f170bbe356eb Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:57:03 +0200 Subject: [PATCH 084/247] [CrashReporting] Sentry send path with consent gating (#7522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CrashReporting] Real send path with consent gating (DSN still placeholder) require_user_consent + give/revoke around the Ask dialog, message event with in-memory crash-log.txt bytes attachment, before_send scrubs message/logentry/breadcrumbs/data of cockatrice:// secrets. Failure box offers Save (shows path) / Discard (revokes + rejects) / Cancel (back to dialog); reject() override so X/Escape cannot bypass revocation; silent AlwaysSend path falls back to a local file. Empty DSN keeps the previous mock behavior byte-identical. Tests: 528/528 ctest green, format clean, code + UI gates PASS. * [CrashReporting] Attach crash log and user note to the pending crash event The redacted log used to ride a separate message event from the native crash (minidump + stack), forcing manual correlation in Sentry. Fold it into the pending crash envelope(s) instead by appending well-formed attachment items before consent releases them: sentry-native replays cached envelopes byte-for-byte (raw read, raw serialize in 0.17.3), so the appended crash-log.txt and user-note.txt items upload with the very event that already carries the dump and stack. Covers both storage locations: Ask mode's consent-held files in /cache and AlwaysSend's transport-dumped files in /.run, which must be attached before sentry_init replays them. When no pending crash event exists (unclean exit without a native crash) the standalone message event remains as fallback, now also carrying the optional note. The dialog gains an optional free-text field ('Here's what I was doing...') that travels as user-note.txt; the mock JSON records it too. * [CrashReporting] Send PROJECT_VERSION as the Sentry release main() reported VERSION_STRING (PROJECT_VERSION_FRIENDLY) as the Sentry release — that carries a build date, so every build of the same version landed in its own release, and it never matched the installer, which reports CPACK/PROJECT_VERSION. Bake COCKATRICE_SENTRY_RELEASE= PROJECT_VERSION instead: date-free, identical between client and installer, and derived from the git tag on tag builds, so events, symbol uploads and the installer log all group under one release. * [CrashReporting] Friendlier crash dialog copy Reword the report dialog along a single friendly arc: headline ('Oops, we crashed — and now we need your help to make Cockatrice better!'), why helping is easy, exactly what gets collected (crash dump with stack trace, app version, OS info, redacted log excerpt), the value of each report ('better and less crashy for everyone'), and the consent choice ('Send and remember makes this automatic without interruption in the future'). The checkbox itself stays direction-neutral ('Remember my choice') because it also persists a 'Don't send' decision. * [CrashReporting] Log where crash reports go and warn when they cannot The client resolved COCKATRICE_SENTRY_DSN (environment over the baked-in value) without ever saying which one won, and sentry-native refuses to build a request for a DSN it considers invalid without reporting anything, so a tester pointing the client at a scratch project had nothing to go on when a crash never appeared. Say which DSN source won, and log the destination (host and project id, never the key) once the SDK is armed. Warn instead of staying quiet when no DSN is configured at all, when a DSN is malformed - disabling reporting rather than silently falling back to the project baked into the build, which is the rule the installer already follows in cmake/CrashReporting.cmake - and when the build was compiled without Sentry support. A deliberate Never-send stays at info level: that is a choice, not a misconfiguration. Also warn when a pending crash envelope was captured for a different destination than the session that will upload it. sentry-native posts every envelope with the DSN held in the transport state of the session doing the sending, and envelopes restored from disk are raw, so the "dsn" header of a cached envelope is never read: a crash captured for one project can be uploaded to another with nothing in the log to show it. Tests cover the DSN shape check (same unanchored pattern the build matches, so a DSN this build accepted is never rejected here), destination description without the key, and the mismatch detection, which stays quiet for a different key on the same project, unreadable files, and headers without a dsn. --------- Co-authored-by: Lukas Brübach --- cmake/CrashReporting.cmake | 7 + .../src/client/crash/crash_reporter.cpp | 409 ++++++++++++++++++ cockatrice/src/client/crash/crash_reporter.h | 97 ++++- .../widgets/dialogs/dlg_crash_report.cpp | 107 ++++- .../widgets/dialogs/dlg_crash_report.h | 22 +- cockatrice/src/main.cpp | 105 +++-- tests/crash_reporter_test.cpp | 265 +++++++++++- 7 files changed, 965 insertions(+), 47 deletions(-) diff --git a/cmake/CrashReporting.cmake b/cmake/CrashReporting.cmake index a728e25c2..abd029cc2 100644 --- a/cmake/CrashReporting.cmake +++ b/cmake/CrashReporting.cmake @@ -32,6 +32,12 @@ function(cockatrice_target_enable_crash_reporting _target) endif() # Always baked in (possibly empty): main() prefers the COCKATRICE_SENTRY_DSN env var at runtime. target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_DSN=\"${COCKATRICE_SENTRY_DSN}\"") + # Sentry release for events from this target: PROJECT_VERSION (getversion.cmake ran before this + # file was included). Deliberately not PROJECT_VERSION_FRIENDLY / VERSION_STRING — that carries + # a build date, so every build of the same version would land in a different Sentry release. + # PROJECT_VERSION matches what the installer reports in its own log and what CPack derives the + # package version from, so client and installer events group under one release. + target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_RELEASE=\"${PROJECT_VERSION}\"") endfunction() if(WITH_CRASH_REPORTING AND WITH_CLIENT) @@ -77,6 +83,7 @@ if(WITH_CRASH_REPORTING AND WITH_CLIENT) sentry GIT_REPOSITORY https://github.com/getsentry/sentry-native.git GIT_TAG ${COCKATRICE_SENTRY_TAG} + GIT_SHALLOW TRUE ) fetchcontent_makeavailable(sentry) diff --git a/cockatrice/src/client/crash/crash_reporter.cpp b/cockatrice/src/client/crash/crash_reporter.cpp index fe1678b11..720bcd350 100644 --- a/cockatrice/src/client/crash/crash_reporter.cpp +++ b/cockatrice/src/client/crash/crash_reporter.cpp @@ -4,15 +4,216 @@ #include #endif +#include #include #include +#include +#include #include #include #include +#include #include +#include Q_LOGGING_CATEGORY(CrashReportLog, "crash_report"); +#if COCKATRICE_WITH_CRASH_REPORTING +namespace +{ + +constexpr char CockatriceUrlScheme[] = "cockatrice://"; + +/** + * @brief Qt-free cockatrice:// secret scrubber for the before_send hook. + * + * before_send does not run in async-signal context on our backends (breakpad invokes it on the + * crashing thread outside the signal handler, and crashpad/native crashes bypass the hook + * entirely), but it still avoids QString/QRegularExpression so it stays safe to call from the + * crash-handler thread. Truncates each URL at the first terminator, keeping scheme://host like + * redactCockatriceUrls does. (std::string allocates, so this is not strictly signal-safe.) + */ +std::string scrubCockatriceUrls(const char *input) +{ + std::string out; + const char *pos = input; + const char *scheme = CockatriceUrlScheme; + const size_t schemeLen = std::strlen(scheme); + while (*pos != '\0') { + const char *found = std::strstr(pos, scheme); + if (found == nullptr) { + out.append(pos); + break; + } + out.append(pos, found); + const char *hostEnd = found + schemeLen; + while (*hostEnd != '\0' && *hostEnd != '/' && *hostEnd != '?' && *hostEnd != '#' && *hostEnd != ' ' && + *hostEnd != '\t' && *hostEnd != '\n' && *hostEnd != '\'' && *hostEnd != '"' && *hostEnd != '<' && + *hostEnd != '>') { + ++hostEnd; + } + out.append(found, hostEnd); + pos = hostEnd; + } + return out; +} + +void scrubStringValue(sentry_value_t holder, const char *key) +{ + sentry_value_t current = sentry_value_get_by_key(holder, key); + if (sentry_value_get_type(current) != SENTRY_VALUE_TYPE_STRING) { + return; + } + const char *raw = sentry_value_as_string(current); + if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) { + return; + } + sentry_value_set_by_key(holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str())); +} + +/** + * @brief Scrub trivially reachable string values inside a breadcrumb data object. + */ +int scrubDataEntry(const char *key, sentry_value_t value, void *userdata) +{ + if (sentry_value_get_type(value) != SENTRY_VALUE_TYPE_STRING) { + return 0; + } + const char *raw = sentry_value_as_string(value); + if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) { + return 0; + } + auto *holder = static_cast(userdata); + sentry_value_set_by_key(*holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str())); + return 0; +} + +void scrubDataObject(sentry_value_t data) +{ + if (sentry_value_get_type(data) != SENTRY_VALUE_TYPE_OBJECT) { + return; + } + sentry_value_foreach_key_value(data, scrubDataEntry, &data); +} + +void scrubCrumbList(sentry_value_t crumbs) +{ + const size_t count = sentry_value_get_length(crumbs); + for (size_t i = 0; i < count; ++i) { + sentry_value_t crumb = sentry_value_get_by_index(crumbs, i); + if (sentry_value_get_type(crumb) != SENTRY_VALUE_TYPE_OBJECT) { + continue; + } + scrubStringValue(crumb, "message"); + scrubDataObject(sentry_value_get_by_key(crumb, "data")); + } +} + +void scrubBreadcrumbs(sentry_value_t event) +{ + // The SDK merges scope breadcrumbs onto the event as a plain list (sentry__value_merge_breadcrumbs + // in sentry_scope.c); the { "values": [...] } envelope shape only exists on the wire. Accept both + // so the scrubbing runs on the actual merged form. + sentry_value_t breadcrumbs = sentry_value_get_by_key(event, "breadcrumbs"); + if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_LIST) { + scrubCrumbList(breadcrumbs); + return; + } + if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_OBJECT) { + sentry_value_t values = sentry_value_get_by_key(breadcrumbs, "values"); + if (sentry_value_get_type(values) == SENTRY_VALUE_TYPE_LIST) { + scrubCrumbList(values); + } + } +} + +/** + * @brief before_send hook: scrub cockatrice:// secrets from the event. + * + * Defense in depth — payloads we build are pre-redacted. No breadcrumbs are recorded today (the Qt + * integration stays off), so this currently covers the top-level message and logentry.formatted, + * plus any breadcrumb messages/data that reach the merged event in the future. Native crash + * minidumps bypass this hook regardless of backend: breakpad captures them in-process without + * invoking before_send, and fast-fail crashes on Windows skip the local handler, so before_send is + * never invoked for those. + */ +sentry_value_t scrubEventBeforeSend(sentry_value_t event, sentry_hint_t *hint, void *userData) +{ + Q_UNUSED(hint); + Q_UNUSED(userData); + scrubStringValue(event, "message"); + sentry_value_t logentry = sentry_value_get_by_key(event, "logentry"); + if (sentry_value_get_type(logentry) == SENTRY_VALUE_TYPE_OBJECT) { + scrubStringValue(logentry, "formatted"); + } + scrubBreadcrumbs(event); + return event; +} + +} // namespace +#endif + +namespace +{ + +/** + * @brief DSN matcher shared by isDsnWellFormed() and describeDsn(). + * + * Kept unanchored on purpose, mirroring the MATCHES in cmake/CrashReporting.cmake (CMake searches + * rather than anchoring), so the client never rejects a DSN the build itself baked in. Compiled once + * because QRegularExpression construction is not free and both helpers are called per pending + * envelope. + */ +const QRegularExpression &dsnPattern() +{ + static const QRegularExpression pattern(QStringLiteral("(https?)://([^@]+)@([^/]+)/([0-9]+)")); + return pattern; +} + +} // namespace + +bool CrashReporter::isDsnWellFormed(const QString &dsn) +{ + return dsnPattern().match(dsn).hasMatch(); +} + +QString CrashReporter::describeDsn(const QString &dsn) +{ + const QRegularExpressionMatch match = dsnPattern().match(dsn); + if (!match.hasMatch()) { + return QString(); + } + // Host and project id only: the key in group 2 must never reach a log line. + return QStringLiteral("%1 (project %2)").arg(match.captured(3), match.captured(4)); +} + +QString CrashReporter::describeOriginMismatch(const QString &envelopePath, const QString ¤tDsn) +{ + const QString destination = describeDsn(currentDsn); + if (destination.isEmpty()) { + return QString(); + } + + QFile file(envelopePath); + if (!file.open(QIODevice::ReadOnly)) { + return QString(); + } + // The envelope header is the first line and is small; the cap only keeps a corrupt file from + // being read in full. + const QByteArray headerLine = file.readLine(4096).trimmed(); + if (headerLine.isEmpty() || !headerLine.startsWith('{')) { + return QString(); + } + const QString originDsn = QJsonDocument::fromJson(headerLine).object().value(QStringLiteral("dsn")).toString(); + const QString origin = describeDsn(originDsn); + // Equal destinations are not a mismatch: a different key for the same host and project still + // uploads where the crash was captured. + if (origin.isEmpty() || origin == destination) { + return QString(); + } + return QStringLiteral("was captured for %1 but this session uploads with %2").arg(origin, destination); +} + bool CrashReporter::isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode) { return !dsn.isEmpty() && consentMode != PersonalSettings::CrashReportingMode::NeverSend; @@ -26,10 +227,28 @@ void CrashReporter::init(const QString &dsn, shutdown(); sentryDatabasePath = databasePath; if (!isReportingEnabled(dsn, consentMode)) { + if (!dsn.isEmpty()) { + // A deliberate choice rather than a misconfiguration, so it is not a warning. The + // no-DSN case is reported by the caller, which is the only place that knows whether + // the environment or the build supplied the DSN. + qCInfo(CrashReportLog) << "crash reporting disabled by preference (Never send)"; + } return; } // Only created when the SDK will actually run: no-DSN launches stay side-effect free. #if COCKATRICE_WITH_CRASH_REPORTING + // A crash captured by an earlier run is uploaded by THIS session's transport, which posts every + // envelope with the DSN it was armed with (sentry-native keeps the DSN in the transport state, + // and envelopes restored from disk are raw, so their own "dsn" header is never read). When the + // two disagree the report lands in a project nobody chose, which no other log line reveals. + const QStringList pendingEnvelopes = pendingEnvelopePaths(sentryDatabasePath); + for (const QString &envelopePath : pendingEnvelopes) { + const QString mismatch = describeOriginMismatch(envelopePath, dsn); + if (!mismatch.isEmpty()) { + qCWarning(CrashReportLog) << "pending crash envelope" << envelopePath << mismatch; + } + } + qCInfo(CrashReportLog) << "crash reporting armed for" << describeDsn(dsn); QDir().mkpath(sentryDatabasePath); if (consentMode != PersonalSettings::CrashReportingMode::AlwaysSend) { // sentry-native persists consent to /user-consent and reloads it in sentry_init @@ -46,9 +265,13 @@ void CrashReporter::init(const QString &dsn, // Consent-gated uploads: crashes are captured into the local database but // nothing leaves the machine until giveConsent(). sentry_options_set_require_user_consent(options, 1); + sentry_options_set_before_send(options, scrubEventBeforeSend, nullptr); ready = (sentry_init(options) == 0); #else Q_UNUSED(release); + // Not a misconfiguration like an empty DSN: this build cannot report whatever the DSN says, so + // a tester forcing a crash needs to know the difference. + qCInfo(CrashReportLog) << "crash reporting unavailable: built without Sentry support"; ready = false; #endif } @@ -80,12 +303,198 @@ void CrashReporter::giveConsent() void CrashReporter::revokeConsent() { #if COCKATRICE_WITH_CRASH_REPORTING + // Revoke (not reset): an explicit refusal blocks uploads; reset would + // return to unknown, which blocks uploads too but loses the decision. if (ready) { sentry_user_consent_revoke(); } #endif } +bool CrashReporter::sendReport(const QString &redactedPreview, const QString &userNote) +{ +#if COCKATRICE_WITH_CRASH_REPORTING + if (!ready) { + return false; + } + // Local scope (not the global one): the log preview rides only on this event. A global + // sentry_attach_bytes would linger and ride along on every later event in the session, + // including a real native crash later on — and with the crashpad backend byte attachments + // are written to disk for exactly that. In-memory bytes also mean no report files land in + // the Sentry DB dir, so there is no disk growth and no same-second filename collision. + sentry_scope_t *scope = sentry_scope_new(); + if (scope == nullptr) { + qCWarning(CrashReportLog) << "Could not create scope for crash report"; + return false; + } + const QByteArray previewBytes = redactedPreview.toUtf8(); + const sentry_uuid_t attachmentId = sentry_scope_attach_bytes( + scope, previewBytes.constData(), static_cast(previewBytes.size()), "crash-log.txt"); + const bool attachOk = sentry_uuid_is_nil(&attachmentId) == 0; + if (!attachOk) { + qCWarning(CrashReportLog) << "Could not attach crash log to crash report"; + } + bool noteAttachOk = true; + if (!userNote.isEmpty()) { + const QByteArray noteBytes = userNote.toUtf8(); + const sentry_uuid_t noteId = sentry_scope_attach_bytes(scope, noteBytes.constData(), + static_cast(noteBytes.size()), "user-note.txt"); + noteAttachOk = sentry_uuid_is_nil(¬eId) == 0; + if (!noteAttachOk) { + qCWarning(CrashReportLog) << "Could not attach user note to crash report"; + } + } + + // Release is attached automatically from the init options. Deliberately no + // sentry_set_user call: the report must carry no PII. The dialog collects + // no contact info, so the message event + attachments are the whole payload + // (sentry_value_new_feedback/sentry_capture_feedback exist but stay unused). + sentry_value_t event = sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "crash-report", "Crash report"); + const sentry_uuid_t eventId = sentry_scope_capture_event(scope, event, nullptr); + sentry_scope_free(scope); + if (sentry_uuid_is_nil(&eventId)) { + qCWarning(CrashReportLog) << "Failed to capture crash report event"; + return false; + } + char eventIdStr[37] = {}; + sentry_uuid_as_string(&eventId, eventIdStr); + // Capture only queues the envelope to the background transport: a network failure stays + // invisible here, so true means "queued", not "delivered". The Save fallback is offered only + // when the local capture itself fails. + qCInfo(CrashReportLog) << "Crash report queued, event id:" << eventIdStr; + // A failed attachment still captures the event above, but reports failure + // so callers offer the local-save fallback instead of silent success. + return attachOk && noteAttachOk; +#else + Q_UNUSED(redactedPreview); + Q_UNUSED(userNote); + return false; +#endif +} + +bool CrashReporter::appendAttachmentToEnvelope(const QString &envelopePath, + const QString &filename, + const QByteArray &payload) +{ + // QIODevice::ReadWrite creates a missing file; appending to a file that was never + // written by the SDK would fabricate an envelope, so require it to exist. + if (!QFile::exists(envelopePath)) { + return false; + } + QFile file(envelopePath); + if (!file.open(QIODevice::ReadWrite)) { + return false; + } + // Item boundaries are single newlines: make sure the header starts on a fresh line. + if (file.size() > 0) { + if (!file.seek(file.size() - 1)) { + return false; + } + char lastByte = 0; + if (file.read(&lastByte, 1) != 1) { + return false; + } + if (lastByte != '\n' && file.write("\n", 1) != 1) { + return false; + } + } + // Item shape mirrors sentry__envelope_add_attachment in the SDK. `length` is what the + // SDK's envelope re-parser uses to frame the payload (a missing length ends the item at + // the next newline, which would truncate a multi-line log and make the rest of the file + // fail to parse); `attachment_length` is the logical attachment size the SDK also emits. + // The filename is a compile-time ASCII constant from the callers, so no JSON escaping is + // needed. + const QByteArray header = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":") + + QByteArray::number(payload.size()) + QByteArrayLiteral(",\"attachment_length\":") + + QByteArray::number(payload.size()) + QByteArrayLiteral(",\"filename\":\"") + + filename.toUtf8() + QByteArrayLiteral("\",\"content_type\":\"text/plain\"}") + '\n'; + if (file.write(header) != header.size()) { + return false; + } + return file.write(payload) == payload.size(); +} + +QStringList CrashReporter::pendingEnvelopePaths(const QString &databasePath) +{ + static const auto isUuid = [](const QString &id) { return id.size() == 36 && id.count(QLatin1Char('-')) == 4; }; + static const auto isEnvelopeName = [](const QString &name) { + if (name == QStringLiteral("__sentry-crash.envelope")) { + return false; + } + const QString stem = name.left(name.size() - 9); // strip ".envelope" + if (isUuid(stem)) { + return true; + } + // Retry cache filename: "--". + const qsizetype firstSep = stem.indexOf(QLatin1Char('-')); + const qsizetype secondSep = firstSep > 0 ? stem.indexOf(QLatin1Char('-'), firstSep + 1) : -1; + return firstSep > 0 && secondSep > 0 && isUuid(stem.mid(secondSep + 1)) && stem.left(firstSep).toLongLong() > 0; + }; + QStringList result; + const QDir dbDir(databasePath); + if (!dbDir.exists()) { + return result; + } + // Consent-held envelopes (Ask mode: the SDK cached them at crash time instead + // of sending, see sentry__submit_envelope). Bare ".envelope" with + // cache_keep only, "--.envelope" with http_retry. + const QDir cacheDir(databasePath + QStringLiteral("/cache")); + const QStringList cacheNames = cacheDir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name); + for (const QString &name : cacheNames) { + if (isEnvelopeName(name)) { + result.append(cacheDir.filePath(name)); + } + } + // In-flight envelopes dumped to the crashed run's own directory (AlwaysSend + // mode: consent was already given, so the transport dump wrote them there and + // sentry_init's process_old_runs replays them at startup — attach before init). + const QStringList runDirs = + dbDir.entryList({QStringLiteral("*.run")}, QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + for (const QString &runDir : runDirs) { + const QDir dir(dbDir.filePath(runDir)); + const QStringList names = dir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name); + for (const QString &name : names) { + if (isEnvelopeName(name)) { + result.append(dir.filePath(name)); + } + } + } + return result; +} + +int CrashReporter::attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText) +{ + return attachToPendingCrashEnvelopes(sentryDatabasePath, redactedLogText, userNoteText); +} + +int CrashReporter::attachToPendingCrashEnvelopes(const QString &databasePath, + const QString &redactedLogText, + const QString &userNoteText) +{ + if (databasePath.isEmpty()) { + return 0; + } + const QByteArray logBytes = redactedLogText.toUtf8(); + const QByteArray noteBytes = userNoteText.toUtf8(); + int attached = 0; + const QStringList envelopes = pendingEnvelopePaths(databasePath); + for (const QString &path : envelopes) { + bool ok = appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), logBytes); + if (ok && !noteBytes.isEmpty()) { + ok = appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), noteBytes); + } + if (ok) { + ++attached; + } else { + qCWarning(CrashReportLog) << "Failed to attach crash log to pending envelope" << path; + } + } + if (attached > 0) { + qCInfo(CrashReportLog) << "Attached crash log and user note to" << attached << "pending crash envelope(s)"; + } + return attached; +} + QString CrashReporter::sanitizeLogLine(const QString &line) { static const auto colorEscapeCodePattern = QRegularExpression(QStringLiteral("\033\\[[0-9;]*m")); diff --git a/cockatrice/src/client/crash/crash_reporter.h b/cockatrice/src/client/crash/crash_reporter.h index f57ec6019..598b6445c 100644 --- a/cockatrice/src/client/crash/crash_reporter.h +++ b/cockatrice/src/client/crash/crash_reporter.h @@ -6,8 +6,10 @@ #ifndef CRASH_REPORTER_H #define CRASH_REPORTER_H +#include #include #include +#include #include #ifndef COCKATRICE_WITH_CRASH_REPORTING @@ -18,6 +20,10 @@ #define COCKATRICE_SENTRY_DSN "" #endif +#ifndef COCKATRICE_SENTRY_RELEASE +#define COCKATRICE_SENTRY_RELEASE "" +#endif + Q_DECLARE_LOGGING_CATEGORY(CrashReportLog); /** @@ -26,12 +32,15 @@ Q_DECLARE_LOGGING_CATEGORY(CrashReportLog); * Single responsibility: init/shutdown the sentry-native SDK behind consent * gating, plus the shared log redaction helpers. An empty DSN (or Never * consent) skips the SDK entirely, so the mock dialog fallback behaves exactly - * as before. init() arms require-user-consent so cached crash envelopes never - * upload before the user is asked; giveConsent()/revokeConsent() (driven by - * the reporting-mode setting and the crash dialog) gate uploads, and any stale - * persisted consent from a previous AlwaysSend run is cleared before init when - * the mode is not AlwaysSend. The real send path is a later increment. All SDK - * calls are guarded so OFF builds compile to no-ops. + * as before. With a DSN, init() arms require-user-consent and before_send + * scrubbing (stale persisted consent from a previous AlwaysSend run is cleared + * before init when the mode is not AlwaysSend); giveConsent()/revokeConsent() + * gate uploads. attachToPendingCrashEnvelopes() folds the redacted log and the + * optional user note into the pending native crash event(s) before consent + * releases them, so dump, stack, log and note arrive as ONE Sentry event; + * sendReport() is the fallback for unclean exits that produced no native crash + * event, capturing a redacted message event with the log preview attached. All + * SDK calls are guarded so OFF builds compile to no-ops. */ class CrashReporter { @@ -49,12 +58,88 @@ public: void shutdown(); void giveConsent(); void revokeConsent(); + bool sendReport(const QString &redactedPreview, const QString &userNote = QString()); + + /** + * @brief Append the redacted log (and optional user note) to every pending + * native crash envelope in the local database, so the crashed event uploads + * with dump, stack, log and note together instead of the log arriving as a + * separate event that has to be correlated by hand. Both storage locations + * are covered: consent-held envelopes in /cache (Ask mode) and + * in-flight envelopes dumped into /.run (AlwaysSend mode). + * + * For the AlwaysSend variant run this BEFORE init() (static overload): the + * startup replay of *.run envelopes happens inside sentry_init. SDK-independent + * plain-file surgery on the Sentry envelope format, so it stays unit-testable + * in OFF builds. + * + * @return the number of envelopes that received the attachments. + */ + int attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText); + static int attachToPendingCrashEnvelopes(const QString &databasePath, + const QString &redactedLogText, + const QString &userNoteText); + + /** + * @brief Append one attachment item to a cached Sentry envelope file. + * + * An envelope is a newline-separated stream of item-header-line + payload + * pairs. The retry transport replays cached envelopes byte-for-byte + * (sentry-native 0.17.3 reads them raw and serializes raw envelopes back + * verbatim), so appending well-formed items associates them with the event + * already in the file; the first line (event id header) is never touched. + * + * @param filename ASCII-only item filename, embedded verbatim in the header. + */ + static bool + appendAttachmentToEnvelope(const QString &envelopePath, const QString &filename, const QByteArray &payload); + + /** + * @brief Pending crash envelopes in : the consent-held files in + * /cache and the in-flight files in /.run. + * + * Matches the cache filename conventions sentry-native accepts (see + * sentry__parse_cache_filename): bare ".envelope" and retry + * "--.envelope"; run dirs accept plain ".envelope" + * minus the SDK's own "__sentry-crash.envelope" marker. + */ + static QStringList pendingEnvelopePaths(const QString &databasePath); /** * @brief SDK-independent gating decision, unit-testable in OFF builds. */ static bool isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode); + /** + * @brief Whether a DSN has the shape the build and the SDK require: + * ://@/. + * + * Deliberately the same unanchored pattern cmake/CrashReporting.cmake matches (CMake's MATCHES + * searches rather than anchoring), so a DSN this build accepted is never rejected here. The SDK + * refuses to even build a request for a DSN it considers invalid, so an unchecked typo disables + * uploading with no error anywhere. + */ + static bool isDsnWellFormed(const QString &dsn); + + /** + * @brief Human-readable destination of a DSN, e.g. "o0.ingest.sentry.io (project 1234567)". + * + * Safe to log: the public key is never part of the result. Empty for a malformed DSN. + */ + static QString describeDsn(const QString &dsn); + + /** + * @brief Describe the captured-for DSN of a pending envelope when it points at a different + * destination than the DSN this session would upload it with; empty when they agree. + * + * sentry-native posts every envelope with the DSN held in the transport state of the session + * doing the sending (envelopes restored from disk are raw and their own "dsn" header is never + * read), so a cached crash envelope captured for one project can silently be uploaded to + * another - the exact way a tester's report lands in the wrong place. Compares destinations + * (host and project id), not raw strings, so a different key for the same project stays quiet. + */ + static QString describeOriginMismatch(const QString &envelopePath, const QString ¤tDsn); + static QString sanitizeLogLine(const QString &line); static QString redactCockatriceUrls(const QString &line); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp index 7fc69e12b..5cd02ebef 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp @@ -15,12 +15,14 @@ #include #include #include +#include #include #include #include #include -DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent) +DlgCrashReport::DlgCrashReport(QWidget *parent, bool _realSending, CrashReporter *_reporter) + : QDialog(parent), realSending(_realSending), reporter(_reporter) { infoLabel = new QLabel(this); infoLabel->setWordWrap(true); @@ -28,6 +30,13 @@ DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent) accountLabel = new QLabel(this); accountLabel->setWordWrap(true); + // Constructed before previewArea: Qt derives the default Tab chain from construction + // order, and note sits visually above the log excerpt, so this keeps Tab moving downward. + noteArea = new QPlainTextEdit(this); + // Optional free text ("what I was doing when it crashed") — a few lines are + // enough for a description, leave the rest of the space to the log excerpt. + noteArea->setMaximumHeight(70); + previewArea = new QPlainTextEdit(this); previewArea->setReadOnly(true); previewArea->setPlainText(buildRedactedPreview()); @@ -51,6 +60,7 @@ DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent) auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(infoLabel); mainLayout->addWidget(accountLabel); + mainLayout->addWidget(noteArea); mainLayout->addWidget(previewArea); mainLayout->addLayout(buttonLayout); setLayout(mainLayout); @@ -70,9 +80,34 @@ void DlgCrashReport::changeEvent(QEvent *event) void DlgCrashReport::retranslateUi() { setWindowTitle(tr("Send crash report")); - infoLabel->setText( - tr("Cockatrice closed unexpectedly. The preview below shows what a crash report would contain (mock).")); - accountLabel->setText(tr("No account needed to send a crash report.")); + if (realSending) { + // The preview below prefers the crashed session's persisted log tail + // (Logger::getPreviousSessionTail), falling back to this restart's in-memory buffer only + // when no previous session was saved. On send it is attached as crash-log.txt to the + // pending native crash event (dump + stack + log + note in one report); when no native + // crash event exists, it rides the fallback message event instead. + infoLabel->setText(tr("Oops, we crashed — and now we need your help to make Cockatrice better!\n\n" + "The good thing is, helping is easy and convenient. Sending uploads what the " + "previous run left behind when it crashed: the crash dump with the stack trace, " + "the app version and operating system info, the redacted log excerpt shown below, " + "and anything you write in the note box. If no log from the crashed session was " + "saved, this restart's early logs are shown instead.\n\n" + "Reports are what let us fix crashes like this one. Check \u201cRemember my choice\u201d " + "and Send, and future crash reports are sent automatically without interruption.")); + } else { + infoLabel->setText(tr("(Mock preview — nothing will be uploaded.)\n\n" + "Cockatrice closed unexpectedly. A real report would contain what the previous run " + "left behind when it crashed: the crash dump with the stack trace, the app version " + "and operating system info, the redacted log excerpt shown below, and anything you " + "write in the note box. If no log from the crashed session was saved, this restart's " + "early logs are shown instead.\n\n" + "A mock file is written locally for inspection.")); + } + accountLabel->setText(tr("No account needed, and no contact info is collected. Login links " + "(cockatrice://...) are scrubbed before sending.")); + noteArea->setPlaceholderText(tr("(Optional) Describe what you were doing when it crashed")); + // Direction-neutral on purpose: the same box also remembers a "Don't send" decision + // (reject() persists NeverSend when it is checked), so it must not promise sending. rememberBox->setText(tr("Remember my choice")); sendButton->setText(tr("Send")); dontSendButton->setText(tr("Don't send")); @@ -103,7 +138,7 @@ QString DlgCrashReport::buildRedactedPreview() return lines.join(QStringLiteral("\n")); } -QString DlgCrashReport::writeMockReport(const QString &previewText) +QString DlgCrashReport::writeMockReport(const QString &previewText, const QString &userNote) { Logger &logger = Logger::getInstance(); @@ -115,6 +150,7 @@ QString DlgCrashReport::writeMockReport(const QString &previewText) report[QStringLiteral("systemLocale")] = logger.getSystemLocale(); report[QStringLiteral("installInfo")] = logger.getClientInstallInfo(); report[QStringLiteral("logPreview")] = previewText; + report[QStringLiteral("userNote")] = userNote; QString fileName = QStringLiteral("crash-report-mock-%1.json") .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmss"))); @@ -134,7 +170,56 @@ void DlgCrashReport::actSend() if (rememberBox->isChecked()) { SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend); } - const QString savedPath = writeMockReport(previewArea->toPlainText()); + const QString preview = previewArea->toPlainText(); + const QString userNote = noteArea->toPlainText().trimmed(); + if (realSending && reporter != nullptr) { + // Fold log and note into the pending native crash event(s) BEFORE consent: + // giveConsent() releases the held envelopes for upload, so dump, stack, + // log and note arrive as one Sentry event. Without a pending crash event + // (unclean exit that produced no native crash) this returns 0 and + // sendReport() falls back to a standalone message event with the same + // attachments. + const int attached = reporter->attachToPendingCrashEnvelopes(preview, userNote); + reporter->giveConsent(); + if (attached > 0 || reporter->sendReport(preview, userNote)) { + accept(); + return; + } + qCWarning(CrashReportLog) << "Failed to send crash report"; + // Outcome mapping for the failure box: Save = store locally and close + // as accepted (the follow-up info box shows the path, so the user + // knows it was NOT sent); Discard = revoke consent and close as + // rejected (dropped); Cancel/X = back to the dialog, neither sent nor + // dropped. The Remember checkbox governs only the original + // Send/Don't-send decision, so the Discard path below revokes and + // closes via QDialog::reject() without touching the setting (calling + // our reject() override here would wrongly persist NeverSend over the + // AlwaysSend stored above when Remember was checked). + QMessageBox failureBox(QMessageBox::Warning, tr("Send crash report"), + tr("Sending the crash report failed. Save the report locally instead?"), + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, this); + failureBox.setDefaultButton(QMessageBox::Save); + failureBox.setEscapeButton(QMessageBox::Cancel); + const QMessageBox::StandardButton choice = static_cast(failureBox.exec()); + if (choice == QMessageBox::Save) { + const QString savedPath = writeMockReport(preview, userNote); + if (savedPath.isEmpty()) { + QMessageBox::warning(this, tr("Send crash report"), tr("Could not save the crash report locally.")); + } else { + QMessageBox::information(this, tr("Send crash report"), + tr("Crash report saved locally to:\n%1").arg(savedPath)); + } + accept(); + return; + } + if (choice == QMessageBox::Discard) { + reporter->revokeConsent(); + QDialog::reject(); + return; + } + return; + } + const QString savedPath = writeMockReport(preview, userNote); if (savedPath.isEmpty()) { qCWarning(CrashReportLog) << "Could not save the mock crash report locally"; } else { @@ -144,9 +229,17 @@ void DlgCrashReport::actSend() } void DlgCrashReport::actDontSend() +{ + reject(); +} + +void DlgCrashReport::reject() { if (rememberBox->isChecked()) { SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend); } - reject(); + if (realSending && reporter != nullptr) { + reporter->revokeConsent(); + } + QDialog::reject(); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h index 576eb3cf9..83808fcbd 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h @@ -15,20 +15,29 @@ class QLabel; class QPlainTextEdit; class QPushButton; +class CrashReporter; + /** - * @brief MOCK crash-report dialog (UI + settings only, no network). + * @brief Crash-report dialog: mock fallback or real Sentry send. * - * The Send path writes a redacted JSON file to the temp dir instead of - * uploading anything. Real Sentry upload comes in a later phase. + * Mock mode (default): the Send path writes a redacted JSON file to the temp + * dir instead of uploading anything. Real mode (realSending, with a live + * CrashReporter): Send first folds the redacted log and the optional user note + * into the pending native crash event(s), then gives consent so dump, stack, + * log and note upload as one Sentry event; when no pending crash event exists + * it falls back to a standalone message event with the same attachments. + * Failure offers a local save instead. */ class DlgCrashReport : public QDialog { Q_OBJECT public: - explicit DlgCrashReport(QWidget *parent = nullptr); + explicit DlgCrashReport(QWidget *parent = nullptr, bool realSending = false, CrashReporter *_reporter = nullptr); static QString buildRedactedPreview(); - static QString writeMockReport(const QString &previewText); + static QString writeMockReport(const QString &previewText, const QString &userNote = QString()); + + void reject() override; protected: void changeEvent(QEvent *event) override; @@ -37,9 +46,12 @@ private: QLabel *infoLabel; QLabel *accountLabel; QPlainTextEdit *previewArea; + QPlainTextEdit *noteArea; QCheckBox *rememberBox; QPushButton *sendButton; QPushButton *dontSendButton; + bool realSending = false; + CrashReporter *reporter = nullptr; void retranslateUi(); diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 38ec3d137..fd98b1945 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -264,23 +264,66 @@ int main(int argc, char *argv[]) qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true); #endif - // Crash reporting (increment 1): init the SDK early so a crash anywhere below is captured into the + // Persistent session log: keeps this run's logs on disk so the next-startup crash dialog can + // attach the crashed session's tail (the in-memory buffer is lost on crash). Opened in Append + // mode with a per-launch session marker; read back via Logger::getPreviousSessionTail(). + // Declared before crash-reporter init: the startup attach below needs the log tail while the + // file still holds the crashed session's lines. + const QString persistentLogPath = + SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log"); + Logger::getInstance().setPersistentLogPath(persistentLogPath); + + // Crash reporting: init the SDK early so a crash anywhere below is captured into the // local database. Uploads stay consent-gated (require_user_consent, armed in CrashReporter::init): // AlwaysSend gives consent here so cached envelopes flush at startup; any other mode starts // unconsented and the dialog below drives give/revoke. An empty DSN skips the SDK entirely and // the mock fallback further below behaves exactly as before. CrashReporter crashReporter; const QString crashDatabasePath = SettingsCache::instance().getSettingsPath() + QStringLiteral("sentry"); + // Envelopes that received the redacted log at startup (AlwaysSend path); 0 means the log was + // NOT folded into a pending crash event and a standalone log event is still needed below. + int crashLogAttachedEnvelopes = 0; { QString crashDsn = QString::fromUtf8(qgetenv("COCKATRICE_SENTRY_DSN")); - if (crashDsn.isEmpty()) { - crashDsn = QString::fromLatin1(COCKATRICE_SENTRY_DSN); + const bool dsnFromEnvironment = !crashDsn.isEmpty(); + if (!dsnFromEnvironment) { + crashDsn = QString::fromUtf8(COCKATRICE_SENTRY_DSN); } - crashReporter.init(crashDsn, QString::fromLatin1(VERSION_STRING), crashDatabasePath, - SettingsCache::instance().personal().getCrashReportingMode()); - if (crashReporter.isReady() && SettingsCache::instance().personal().getCrashReportingMode() == - PersonalSettings::CrashReportingMode::AlwaysSend) { - // Silent opt-in: cached crash envelopes flush automatically once consent is given. + if (crashDsn.isEmpty()) { + // Nothing is armed without a DSN, so no crash is ever captured and nothing is ever sent. + // It is the one disabled configuration a tester cannot see from the UI, so say it here. + qCWarning(CrashReportLog) << "crash reporting disabled: no DSN configured (COCKATRICE_SENTRY_DSN is " + "not set and this build has no DSN baked in)"; + } else if (!CrashReporter::isDsnWellFormed(crashDsn)) { + // Same rule the installer applies (cmake/CrashReporting.cmake): a malformed DSN disables + // reporting rather than silently falling back to the project baked into the build, which + // would upload where the tester never chose. The SDK would otherwise refuse to build a + // request at all and report nothing about it. + qCWarning(CrashReportLog) << "crash reporting disabled:" + << (dsnFromEnvironment ? "COCKATRICE_SENTRY_DSN is set but malformed" + : "the DSN baked into this build is malformed") + << "(expected ://@/)"; + crashDsn.clear(); + } else if (dsnFromEnvironment) { + // Destination and mode are logged by CrashReporter::init() once it resolves them. + qCInfo(CrashReportLog) << "using COCKATRICE_SENTRY_DSN from the environment"; + } + const PersonalSettings::CrashReportingMode crashMode = + SettingsCache::instance().personal().getCrashReportingMode(); + // AlwaysSend folds the crashed session's redacted log into the pending crash + // envelope(s) BEFORE init: sentry_init replays the crashed run's *.run + // envelopes immediately when consent is already persisted, so attaching any + // later would be too late. Consent given below then releases them, and dump, + // stack and log upload as a single Sentry event. Ask mode attaches at the + // dialog instead — its cache-held envelopes stay untouched until giveConsent(). + if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend && + CrashReporter::isReportingEnabled(crashDsn, crashMode)) { + crashLogAttachedEnvelopes = CrashReporter::attachToPendingCrashEnvelopes( + crashDatabasePath, DlgCrashReport::buildRedactedPreview(), QString()); + } + crashReporter.init(crashDsn, QString::fromUtf8(COCKATRICE_SENTRY_RELEASE), crashDatabasePath, crashMode); + if (crashReporter.isReady() && crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { + // Silent opt-in: cached native crash envelopes flush automatically once consent is given. crashReporter.giveConsent(); } // Mid-session mode changes must reach the SDK: without this, switching from AlwaysSend to @@ -297,13 +340,6 @@ int main(int argc, char *argv[]) }); } - // Persistent session log: keeps this run's logs on disk so the next-startup crash dialog can - // attach the crashed session's tail (the in-memory buffer is lost on crash). Opened in Append - // mode with a per-launch session marker; read back via Logger::getPreviousSessionTail(). - const QString persistentLogPath = - SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log"); - Logger::getInstance().setPersistentLogPath(persistentLogPath); - // Translations #ifdef Q_OS_MAC translationPath = qApp->applicationDirPath() + "/../Resources/translations"; @@ -323,7 +359,8 @@ int main(int argc, char *argv[]) {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}, {"simulate-crash", - QCoreApplication::translate("main", "Show the mock crash-report dialog (nothing is sent)")}}); + QCoreApplication::translate("main", + "Show the crash-report dialog (saves a mock file when no DSN is configured)")}}); parser.process(app); @@ -460,10 +497,11 @@ int main(int argc, char *argv[]) ui.show(); qCInfo(MainLog) << "ui.show() finished"; - // MOCK crash-report flow (UI + settings only): no sentry-native upload, no network. - // Triggered by --simulate-crash or by leftover Windows crash dumps. Handled dumps are deleted - // after Send / Don't send (or a silent NeverSend skip) so one crash never prompts twice; on - // non-Windows nothing writes cockatrice.crash.*.dmp yet, so --simulate-crash is the trigger there. + // Crash-report flow: with a configured DSN the SDK owns capture and upload + // (consent-gated); with an empty DSN the dialog falls back to a local mock + // file and behaves exactly as before. Triggered by --simulate-crash or by + // leftover Windows crash dumps, which are consumed after handling so one + // crash never prompts twice. { QString crashDumpDir; #ifdef Q_OS_WIN @@ -487,18 +525,31 @@ int main(int argc, char *argv[]) if (parser.isSet("simulate-crash") || !crashDumps.isEmpty()) { const PersonalSettings::CrashReportingMode crashMode = SettingsCache::instance().personal().getCrashReportingMode(); + const bool crashReady = crashReporter.isReady(); if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { - crashReporter.giveConsent(); - DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); - } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { - DlgCrashReport crashDialog(&ui); - if (crashDialog.exec() == QDialog::Accepted) { - crashReporter.giveConsent(); + if (crashReady) { + // Silent path: the redacted log was folded into the pending native crash + // envelope(s) at startup and consent already released them. Only when no + // pending crash event existed (e.g. --simulate-crash) does a standalone + // log event still need to go out here. + if (crashLogAttachedEnvelopes == 0) { + const QString crashPreview = DlgCrashReport::buildRedactedPreview(); + if (!crashReporter.sendReport(crashPreview)) { + const QString savedPath = DlgCrashReport::writeMockReport(crashPreview); + qCWarning(MainLog) << "Failed to send crash report, saved locally to" << savedPath; + } + } } else { - crashReporter.revokeConsent(); + DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); } + } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { + // Real mode: Send gives consent + captures the event, Don't + // send revokes consent; the dialog owns both via the reporter. + DlgCrashReport crashDialog(&ui, crashReady, crashReady ? &crashReporter : nullptr); + crashDialog.exec(); } // NeverSend: skip silently (no dialog, nothing written or sent). + // Handled dumps are consumed so one crash never prompts twice. for (const QString &dump : crashDumps) { QFile::remove(crashDumpDir + QStringLiteral("/") + dump); } diff --git a/tests/crash_reporter_test.cpp b/tests/crash_reporter_test.cpp index c52a2e495..e15007f7a 100644 --- a/tests/crash_reporter_test.cpp +++ b/tests/crash_reporter_test.cpp @@ -1,6 +1,10 @@ #include "client/crash/crash_reporter.h" +#include +#include +#include #include +#include #include #include #include @@ -39,7 +43,7 @@ TEST(CrashReporterTest, RedactCockatriceUrlsLeavesOtherTextAlone) TEST(CrashReporterTest, InitSkippedWithoutDsn) { CrashReporter reporter; - reporter.init(QString(), QStringLiteral("3.1.2"), QStringLiteral("/tmp/cockatrice-crash-test"), + reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), PersonalSettings::CrashReportingMode::Ask); ASSERT_FALSE(reporter.isReady()); reporter.shutdown(); @@ -50,7 +54,8 @@ TEST(CrashReporterTest, InitSkippedWhenNeverSend) { CrashReporter reporter; reporter.init(QStringLiteral("https://key@sentry.io/1"), QStringLiteral("3.1.2"), - QStringLiteral("/tmp/cockatrice-crash-test"), PersonalSettings::CrashReportingMode::NeverSend); + QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::NeverSend); ASSERT_FALSE(reporter.isReady()); reporter.shutdown(); } @@ -70,6 +75,68 @@ TEST(CrashReporterTest, ReportingEnabledGating) ASSERT_FALSE(CrashReporter::isReportingEnabled(dsn, Mode::NeverSend)); } +TEST(CrashReporterTest, IsDsnWellFormedAcceptsWhatTheBuildAcceptsAndRejectsUiLinks) +{ + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://abc123@o0.ingest.sentry.io/1234567"))); + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("http://key@localhost:9000/42"))); + // Unanchored like cmake/CrashReporting.cmake's MATCHES, so a DSN the build baked is never + // rejected here: a trailing path still matches. + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/7/extra"))); + // The project-settings URL is the mistake a tester actually makes. + ASSERT_FALSE(CrashReporter::isDsnWellFormed( + QStringLiteral("https://sentry.io/settings/cockatrice/projects/cockatrice-desktop/keys/"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/not-a-number"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("o0.ingest.sentry.io/1234567"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QString())); +} + +TEST(CrashReporterTest, DescribeDsnNamesHostAndProjectWithoutTheKey) +{ + const QString described = + CrashReporter::describeDsn(QStringLiteral("https://supersecretkey@o0.ingest.sentry.io/1234567")); + ASSERT_EQ(described, QStringLiteral("o0.ingest.sentry.io (project 1234567)")); + ASSERT_FALSE(described.contains(QStringLiteral("supersecretkey"))); + ASSERT_TRUE(CrashReporter::describeDsn(QStringLiteral("not a dsn")).isEmpty()); + ASSERT_TRUE(CrashReporter::describeDsn(QString()).isEmpty()); +} + +TEST(CrashReporterTest, DescribeOriginMismatchReportsOnlyRealDestinationChanges) +{ + const QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString envelopePath = dir.filePath(QStringLiteral("crash.envelope")); + QFile file(envelopePath); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(QByteArrayLiteral("{\"event_id\":\"x\",\"dsn\":\"https://keyA@hostA.example/111\"}\n")); + file.write(QByteArrayLiteral("{\"type\":\"event\",\"length\":2}\n{}\n")); + file.close(); + + const QString described = + CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://keyB@hostB.example/222")); + ASSERT_FALSE(described.isEmpty()); + ASSERT_TRUE(described.contains(QStringLiteral("hostA.example"))); + ASSERT_TRUE(described.contains(QStringLiteral("hostB.example"))); + ASSERT_FALSE(described.contains(QStringLiteral("keyA"))); + ASSERT_FALSE(described.contains(QStringLiteral("keyB"))); + + // A different key for the SAME host and project is not a routing change and must stay quiet. + ASSERT_TRUE( + CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://otherkey@hostA.example/111")) + .isEmpty()); + // An unreadable envelope, a header without a dsn, or no current dsn are all silent. + ASSERT_TRUE(CrashReporter::describeOriginMismatch(dir.filePath(QStringLiteral("absent.envelope")), + QStringLiteral("https://keyB@hostB.example/222")) + .isEmpty()); + ASSERT_TRUE(CrashReporter::describeOriginMismatch(envelopePath, QString()).isEmpty()); + const QString headerlessPath = dir.filePath(QStringLiteral("no-dsn.envelope")); + QFile headerless(headerlessPath); + ASSERT_TRUE(headerless.open(QIODevice::WriteOnly)); + headerless.write(QByteArrayLiteral("{\"event_id\":\"y\"}\n")); + headerless.close(); + ASSERT_TRUE(CrashReporter::describeOriginMismatch(headerlessPath, QStringLiteral("https://keyB@hostB.example/222")) + .isEmpty()); +} + TEST(CrashReporterTest, ShutdownWithoutInitIsSafe) { CrashReporter reporter; @@ -78,6 +145,200 @@ TEST(CrashReporterTest, ShutdownWithoutInitIsSafe) ASSERT_FALSE(reporter.isReady()); } +TEST(CrashReporterTest, ConsentAndSendWithoutDsnAreSafeNoOps) +{ + CrashReporter reporter; + reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::Ask); + ASSERT_FALSE(reporter.isReady()); + reporter.giveConsent(); + reporter.revokeConsent(); + ASSERT_FALSE(reporter.isReady()); + ASSERT_FALSE(reporter.sendReport(QStringLiteral("preview"))); + ASSERT_FALSE(reporter.sendReport(QString())); + reporter.shutdown(); + ASSERT_FALSE(reporter.isReady()); +} + +namespace +{ + +QByteArray readFileBytes(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +bool writeFileBytes(const QString &path, const QByteArray &content) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + return file.write(content) == content.size(); +} + +QString uuidOf(char marker) +{ + // 36 chars, 4 hyphens — structurally a UUID, content irrelevant for these tests. + return QStringLiteral("00000000-0000-4000-8000-00000000000%1").arg(marker); +} + +} // namespace + +TEST(CrashReporterTest, AppendAttachmentAppendsWellFormedItemAfterExistingContent) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + // Existing envelope content deliberately WITHOUT a trailing newline. + const QByteArray original = QByteArrayLiteral("{\"event_id\":\"x\"}\n{\"type\":\"event\",\"length\":2}\n{}"); + ASSERT_TRUE(writeFileBytes(path, original)); + + ASSERT_TRUE( + CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), QByteArrayLiteral("hello"))); + const QByteArray expected = original + '\n' + + QByteArrayLiteral("{\"type\":\"attachment\",\"length\":5,\"attachment_length\":5," + "\"filename\":\"crash-log.txt\",\"content_type\":\"text/plain\"}\n") + + QByteArrayLiteral("hello"); + ASSERT_EQ(readFileBytes(path), expected); +} + +TEST(CrashReporterTest, AppendAttachmentOnEmptyFileHasNoLeadingNewline) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + ASSERT_TRUE(writeFileBytes(path, QByteArray())); + + ASSERT_TRUE( + CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), QByteArrayLiteral("note"))); + const QByteArray expected = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":4,\"attachment_length\":4," + "\"filename\":\"user-note.txt\",\"content_type\":\"text/plain\"}\n") + + QByteArrayLiteral("note"); + ASSERT_EQ(readFileBytes(path), expected); +} + +TEST(CrashReporterTest, AppendAttachmentRejectsMissingFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + ASSERT_FALSE(CrashReporter::appendAttachmentToEnvelope(dir.filePath(QStringLiteral("missing.envelope")), + QStringLiteral("crash-log.txt"), QByteArrayLiteral("x"))); +} + +TEST(CrashReporterTest, AppendAttachmentLengthIsByteAccurate) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}"))); + + const QByteArray utf8Payload = QString::fromUtf8("héllo").toUtf8(); + ASSERT_EQ(utf8Payload.size(), 6); // 5 characters, "é" is 2 bytes + ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), utf8Payload)); + const QByteArray content = readFileBytes(path); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"length\":6"))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"attachment_length\":6"))); + ASSERT_TRUE(content.endsWith(utf8Payload)); +} + +// The SDK's envelope re-parser frames each item's payload by its `length` header; a +// missing or wrong length ends the payload at the first newline, so the remaining log +// lines get parsed as item headers and the whole envelope is rejected (and deleted). +TEST(CrashReporterTest, AppendAttachmentKeepsMultiLinePayloadIntact) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}"))); + + const QByteArray payload = QByteArrayLiteral("first line\nsecond line\nthird line"); + ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), payload)); + const QByteArray content = readFileBytes(path); + ASSERT_TRUE(content.contains("\"length\":" + QByteArray::number(payload.size()))); + ASSERT_TRUE(content.contains("\"attachment_length\":" + QByteArray::number(payload.size()))); + ASSERT_TRUE(content.endsWith(payload)); +} + +TEST(CrashReporterTest, PendingEnvelopePathsMatchesSdkNamingInCacheAndRunDirs) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString dbPath = dir.path(); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache"))); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/aaaa.run"))); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/other"))); + + const QString retryName = QStringLiteral("1700000000000-0-") + uuidOf('a') + QStringLiteral(".envelope"); + const QString bareName = uuidOf('b') + QStringLiteral(".envelope"); + // Valid: retry-style cache file + bare run-dir file. + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + retryName, QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + bareName, QByteArrayLiteral("{}"))); + // Rejected: junk names and the SDK's own marker file. + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + uuidOf('c') + QStringLiteral(".envelope.bak"), + QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + QStringLiteral("random.envelope"), + QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + QStringLiteral("__sentry-crash.envelope"), + QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/other/") + bareName, QByteArrayLiteral("{}"))); + + const QStringList found = CrashReporter::pendingEnvelopePaths(dbPath); + ASSERT_EQ(found.size(), 2); + ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/cache/") + retryName)); + ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/aaaa.run/") + bareName)); +} + +TEST(CrashReporterTest, PendingEnvelopePathsWithoutDatabaseIsEmpty) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(dir.filePath(QStringLiteral("no-such-db"))).isEmpty()); + ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(QString()).isEmpty()); +} + +TEST(CrashReporterTest, AttachFoldsLogAndNoteIntoPendingEnvelopes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString dbPath = dir.path(); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache"))); + const QString envelopePath = dbPath + QStringLiteral("/cache/") + uuidOf('d') + QStringLiteral(".envelope"); + ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n"))); + + CrashReporter reporter; + // Empty DSN: no SDK, but init() still records the database path the dialog uses. + reporter.init(QString(), QStringLiteral("3.1.2"), dbPath, PersonalSettings::CrashReportingMode::Ask); + ASSERT_EQ(reporter.attachToPendingCrashEnvelopes(QStringLiteral("log line"), QStringLiteral("my note")), 1); + + const QByteArray content = readFileBytes(envelopePath); + ASSERT_TRUE(content.startsWith(QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n"))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\""))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\nlog line"))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"user-note.txt\""))); + ASSERT_TRUE(content.endsWith(QByteArrayLiteral("\nmy note"))); +} + +TEST(CrashReporterTest, AttachSkipsNoteWhenEmptyAndFindsRunDirEnvelopes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString dbPath = dir.path(); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/bbbb.run"))); + const QString envelopePath = dbPath + QStringLiteral("/bbbb.run/") + uuidOf('e') + QStringLiteral(".envelope"); + ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{}"))); + + // Static overload (the pre-init AlwaysSend path) with an empty note. + ASSERT_EQ(CrashReporter::attachToPendingCrashEnvelopes(dbPath, QStringLiteral("log only"), QString()), 1); + const QByteArray content = readFileBytes(envelopePath); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\""))); + ASSERT_FALSE(content.contains(QByteArrayLiteral("user-note.txt"))); +} + } // namespace int main(int argc, char **argv) From 9f59f5c495f80432d9fd70c224fa88f86f30e6ab Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:57:04 +0200 Subject: [PATCH 085/247] [CrashReporting] Breakpad capture, session detection and symbol upload (#7523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CrashReporting] Breakpad everywhere, session detection, symbol CI (inc 3) Breakpad on all platforms (no crashpad packaging); remove the Windows MiniDumpWithFullMemory filter and the installer WER LocalDumps full-dump registration. QLockFile per-process session tracking replaces the Windows-only .dmp scan, placed after parser.process and the instance hand-off so help/version exits and handed-off secondaries never leak a lock. CI: Release-only DSN bake from SENTRY_DSN secret, tag-only sentry-cli symbol upload (org cockatrice, project native). Tests: 533/533 ctest green, format clean, code gate PASS. * [CrashReporting] Enable http_retry, harden symbol upload, fix NSIS context Sets sentry_options_set_http_retry so the cache_keep-held crash envelope in Ask mode is written with a retry filename and actually flushes on giveConsent() instead of sitting in /cache until pruning. Adds continue-on-error to both sentry-cli install steps so a Sentry registry outage or broken Git Bash install cannot block release assets. Cleans the legacy WER CrashDumps folder under SetShellVarContext current (under all it resolves to ProgramData, not the user's AppData) in installer and uninstaller; registry delete works in either context. * [CrashReporting] Extract the startup crash-report flow from main() per review * [CrashReporting] Break up the CrashReporter class doc per review * [CrashReporting] Drop the invalid --release from the symbol upload `debug-files upload` never accepted `--release` (checked against the pinned sentry-cli 3.8.0: the subcommand declares --org/--project and a set of type/id/scan options, and execute() reads no release; the only global flags are --url/--header/--auth-token/--log-level/--quiet/--allow-failure). Clap rejects the unknown flag before any upload happens, so every tag build has been shipping without symbols, with continue-on-error keeping the release pipeline green. Release association is also the wrong mechanism: debug information files are matched to events by debug ID via the event's debug_meta. The Sentry release of a native event comes from the event itself (COCKATRICE_SENTRY_RELEASE), not from the DIF upload. Remove the flag and the now-unused SENTRY_RELEASE env from both invocations (Linux, and Windows/macOS). The symbol upload stays continue-on-error (a Sentry outage must not block the release assets); revisit once it has proven itself on a tag. * [CrashReporting] Split debug information out of packaged release binaries -g is required so the CI symbol upload has DWARF to send, but it was left in the shipped binaries too: the Linux Release client is 563 MiB, 552 MiB of it debug info, and every distribution channel pays for that. Keep the build tree unchanged (CI uploads symbols from build/) and strip only the packaged copy. A CPack pre-build script extracts each ELF's debug info to COCKATRICE_DEBUG_SYMBOL_DIR and then runs `strip --strip-debug --strip-unneeded`. .eh_frame is allocatable and survives, so unwinding is unaffected, and the build ID is identical in the stripped binary and the sidecar. Measured on a local Release build (GCC -O2 -g, WITH_CRASH_REPORTING=ON), bytes: binary build tree packaged before packaged after .debug sidecar cockatrice 590,722,560 590,722,560 28,086,480 579,623,576 oracle 53,432,608 53,432,608 1,649,888 52,075,344 tar.gz of both trees: 289,964,521 -> 19,018,058 (~15x smaller). Only ELF (Linux/BSD) is handled: Windows already links /DEBUG with separate PDBs, and macOS extracts a dSYM from the bundle. Opt-out via COCKATRICE_SPLIT_DEBUG_SYMBOLS; skipped with a warning when objcopy/strip are missing. CPACK_PRE_BUILD_SCRIPTS (not CPACK_INSTALL_SCRIPTS, which runs before staging) invokes it, and the script refuses to run unless CPACK_TEMPORARY_DIRECTORY is a real CPack staging tree so a missing variable can never turn its glob into a filesystem-wide walk. * [CrashReporting] Upload Windows PDBs from where Ninja actually puts them The Windows branch of the symbol upload looked in build//Release, which is the Visual Studio generator's layout. The Windows leg uses Ninja, which is single-config: linker PDBs land in build//, next to the binaries — the same paths the "[Windows] Upload PDBs" artifact step below globs with if-no-files-found: error. So paths was always empty on Windows, the step printed "No symbol paths found, skipping symbol upload" and exited 0: a silent no-op behind continue-on-error. Every Windows build would then report "A required debug information file was missing" for cockatrice.exe with a PDB-style debug id (GUID-age), exactly as if no upload step existed at all. Use the artifact step's globs. Also turn the empty-path case into a failure in both symbol steps: a silent skip is indistinguishable from a successful upload (which is how the wrong path went unnoticed), and continue-on-error already keeps a broken upload from failing the release. Verified by extracting both steps' run blocks and executing the Windows branch against a Ninja-style tree: it now passes the three linker PDBs to sentry-cli (and not the CMakeFiles/*.pdb intermediates), and exits non-zero with an ::error:: annotation when there is nothing to upload. * [CrashReporting] Archive PDBs on tag builds so a failed upload is recoverable The "[Windows] Upload PDBs" artifact step skipped tag builds (github.ref_type != 'tag'), so for a release the symbols existed only inside the Sentry upload — which is continue-on-error. A failed upload therefore lost the ability to symbolicate that release permanently: no rebuild produces the same PDB, since the debug id is a per-build GUID+age. That is how every Windows crash reported so far became unreadable. Archive the PDBs for tag builds as well, and order the step after "Upload to release" so a missing PDB can never block a release from being published. Retention stays at the repository default; nothing consumes the artifact automatically, it is the manual recovery path (gh run download -n -PDBs, then sentry-cli debug-files upload). Verified by re-parsing the workflow and asserting the step order (release -> PDBs -> attest) and the broadened condition. * [CrashReporting] Upload the Windows executables with their PDBs Sentry needs two things per image on Windows: symbolication and stack unwinding. The PDB covers symbolication, but for a 64-bit PE the Call Frame Information (.pdata/.xdata) lives in the executable and is not copied into the PDB, so uploading PDBs alone leaves the image at "Stack Unwinding: Missing". That is exactly what the event's Images panel showed for cockatrice.exe, while Sentry's own Microsoft symbol server resolved KERNELBASE.dll and ucrtbase.dll from the same dump. sentry-cli also needs the pair in the same invocation: only then does it associate the PE's code identifier with the PDB's debug identifier, otherwise "the identifiers diverge and Sentry might not be able to resolve the correct file for symbolication" (docs: Debug Identifiers). Do the same for macOS, where the dSYM carries the debug information but the Mach-O carries the unwind information, and include the executables in the symbol artifact so a re-upload still has the pair to offer. Verified by extracting the step and running both branches against stub harnesses: Windows passes the three exe/pdb pairs in a single sentry-cli invocation and still fails loudly on an empty tree; macOS passes the dSYM and the Mach-O together. * [CrashReporting] Upload the shipped Qt runtime DLLs too Qt ships no PDBs with its MSVC SDK, so every Qt frame in a crash report resolved to nothing: an image with neither PDB nor PE has no unwind information (on 64-bit Windows the Call Frame Information lives in the PE and is not copied into the PDB) and no symbol source to fall back on. The list is required-runtime.txt, the manifest .ci/compile.sh already asserts the installer against, so what gets uploaded is exactly what an install carries. Qt frames now get unwinding plus export-table names; line numbers still need Qt's debug information files, which the SDK omits. The guards are not cosmetic: the step runs under `bash -eo pipefail`, where a sed over a missing file or an unmatched ls glob aborts the whole step, and continue-on-error makes that look identical to a successful upload. * [CrashReporting] Upload only the binaries, archive symbols only on tags The Linux leg handed sentry-cli whole build directories: 471 candidate files, of which 7 were object files that can never match a stack frame. Only the three linked binaries carry the build-ids Sentry looks up; the packaged copy is split and stripped but keeps that same build-id. The Windows symbol archive ran on every packaging build even though only tag builds upload to Sentry, so a PR build paid for an artifact nothing could ever consume. Tags keep it as the durable copy for a failed upload. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 8 + .github/workflows/desktop-build.yml | 222 +++++++++++++++++- cmake/CrashReporting.cmake | 60 ++++- cmake/NSIS.template.in | 28 ++- cmake/SplitDebugSymbols.cmake.in | 80 +++++++ .../src/client/crash/crash_reporter.cpp | 90 ++++++- cockatrice/src/client/crash/crash_reporter.h | 54 +++-- cockatrice/src/main.cpp | 183 +++++---------- tests/crash_reporter_test.cpp | 79 +++++++ 9 files changed, 648 insertions(+), 156 deletions(-) create mode 100644 cmake/SplitDebugSymbols.cmake.in diff --git a/.ci/compile.sh b/.ci/compile.sh index 2aeeaa6ec..63c027473 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -206,6 +206,14 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then # developer builds never reach Sentry. flags+=("-DWITH_CRASH_REPORTING=ON") fi +if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then + # Release-only: Debug builds stay DSN-free so crashes from developer builds + # never reach Sentry. Empty (local builds, forks without the secret, and non-tag + # CI builds, which never receive the secret) keeps today's mock fallback via the + # compiled-in default. CI passes the value from the SENTRY_DSN secret on tag + # Release builds only, so pre-release test artifacts never report to Sentry. + flags+=("-DCOCKATRICE_SENTRY_DSN=$COCKATRICE_SENTRY_DSN") +fi # Add cmake --build flags buildflags=(--config "$BUILDTYPE") diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 2215ac2d2..c97b48342 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -46,6 +46,11 @@ jobs: outputs: tag: ${{ steps.configure.outputs.tag }} sha: ${{ steps.configure.outputs.sha }} + # MAJ.MIN.PATCH[-beta[.N]] of a tag build; empty otherwise. Mirrors what + # cmake/getversion.cmake derives as PROJECT_VERSION, which the client bakes as its + # Sentry release (cmake/CrashReporting.cmake) — symbol uploads must use the same + # string so stack traces de-symbolicate against this build's binaries. + sentry_release: ${{ steps.configure.outputs.sentry_release }} steps: - name: "Configure" @@ -56,6 +61,9 @@ jobs: run: | if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + # Tag format is YYYY-MM-DD-{Release|Development}-MAJ.MIN.PATCH[-beta[.N]]; + # PROJECT_VERSION is the tail after the type (see cmake/getversion.cmake). + echo "sentry_release=$(sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}-(Release|Development)-//' <<< "$GITHUB_REF_NAME")" >> "$GITHUB_OUTPUT" fi echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT" @@ -240,6 +248,11 @@ jobs: package: '${{ matrix.package }}' server_only: '${{ matrix.server_only }}' WITH_CRASH_REPORTING: 'ON' + # Tag builds only: artifacts from master pushes and PR branches must not report to + # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. + # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN + # and the symbol upload below just like full releases. + COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }} run: | source .ci/docker.sh args=() @@ -248,9 +261,58 @@ jobs: args+=(--ccache "$CCACHE_SIZE") args+=(--cmake-generator "$CMAKE_GENERATOR") args+=(--suffix "$SUFFIX") + # The container inherits no environment, so the DSN rides in explicitly. -e without + # =value passes it through (possibly empty); compile.sh only consumes it for Release + # configures, and empty keeps the mock fallback. + export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN" RUN --server --release --package "$package" "${args[@]}" + - name: "Install sentry-cli" + if: matrix.package != 'skip' && needs.configure.outputs.tag != null + # Downloads from Sentry's release registry: a Sentry outage or a broken install under + # Git Bash must not take down the release assets below. + continue-on-error: true + shell: bash + env: + SENTRY_CLI_VERSION: '3.8.0' + run: | + export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli" + curl -sL https://sentry.io/get-cli/ | bash + echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH" + + - name: "Upload debug symbols to Sentry" + # Like the DSN above, this runs for every tag — beta (Development) tags included. + if: matrix.package != 'skip' && needs.configure.outputs.tag != null + # A Sentry outage or rotated token must not take down the release assets below. + continue-on-error: true + shell: bash + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + paths=() + # The binaries only, not the directories: a directory makes sentry-cli walk the + # whole build tree, where it finds (and uploads) object files and other fragments + # that can never match a stack frame. The packaged copy is split and stripped by + # cpack, but keeps this binary's GNU build-id, which is how Sentry matches it. + for app in cockatrice oracle servatrice; do + binary="build/$app/$app" + [[ -f $binary ]] && paths+=("$binary") + done + if [[ ${#paths[@]} == 0 ]]; then + # Fail loudly instead of skipping: see the Windows/macOS leg below — this step + # is the only way symbols reach Sentry and `continue-on-error` keeps the job + # green either way, so a silent skip looks exactly like a successful upload. + echo "::error::No debug symbol files found; nothing would be uploaded" + exit 1 + fi + # Project slug on Sentry; must match the project the baked DSN points at. + # No --release here: `debug-files upload` has no such option (sentry-cli errors on + # the unknown flag and uploads nothing), and debug information files are matched to + # events by debug ID via the event's debug_meta, not by release. The release grouping + # comes from the client event itself (COCKATRICE_SENTRY_RELEASE). + sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" + # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit @@ -536,6 +598,11 @@ jobs: BUILDTYPE: '${{ matrix.type }}' CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }} CMAKE_GENERATOR: ${{ matrix.cmake_generator }} + # Tag builds only: artifacts from master pushes and PR branches must not report to + # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. + # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN + # and the symbol upload below just like full releases. + COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }} DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} @@ -563,6 +630,121 @@ jobs: WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }} run: .ci/compile.sh --server --test --vcpkg + - name: "Install sentry-cli" + if: needs.configure.outputs.tag != null && matrix.make_package == '1' + # Same as the Linux leg above: the install downloads from Sentry's registry, so a + # failure here must not block the release upload below. + continue-on-error: true + shell: bash + env: + SENTRY_CLI_VERSION: '3.8.0' + run: | + export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli" + curl -sL https://sentry.io/get-cli/ | bash + echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH" + + - name: "Upload debug symbols to Sentry" + # Like the DSN above, this runs for every tag — beta (Development) tags included. + if: needs.configure.outputs.tag != null && matrix.make_package == '1' + # A Sentry outage or rotated token must not take down the release assets below. + continue-on-error: true + shell: bash + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + paths=() + if [[ '${{ matrix.os }}' == 'Windows' ]]; then + # Top-level CMakeLists.txt links /DEBUG even in release, so the linker PDBs + # land next to the binaries. Ninja is single-config, so that is + # build// and NOT build//Release/ — the same globs the symbol + # artifact step below uses. + # + # The executables are uploaded as well, in the same invocation. On 64-bit + # Windows the Call Frame Information (.pdata/.xdata) lives in the PE and is + # not copied into the PDB, so a PDB on its own leaves Sentry reporting + # "Stack Unwinding: Missing" for the image. sentry-cli also needs to see the + # PE and its PDB together: only then does it associate the PE's code + # identifier with the PDB's debug identifier. + shopt -s nullglob + for target in cockatrice oracle servatrice; do + paths+=("build/$target"/*.exe "build/$target"/*.pdb) + done + shopt -u nullglob + + # Qt's own runtime DLLs, taken from required-runtime.txt — the manifest + # .ci/compile.sh asserts the installer against — so the set uploaded is + # the set an install actually carries. The PE is the only carrier of + # unwind information for a 64-bit image, and sending it in the same + # invocation as a PDB is what lets sentry-cli associate the PE's code + # identifier with the PDB's debug identifier. Qt ships no PDBs with the + # SDK, so Qt frames resolve through the export table: names, no lines. + qt_dir= + qt_core_dir= + # Existence guards matter: the step runs under `bash -eo pipefail`, so a + # redirection-less sed over a missing file would abort the step outright + # and continue-on-error would hide it. + if [[ -f build/CMakeCache.txt ]]; then + qt_core_dir=$(sed -n 's/^Qt6Core_DIR:PATH=//p' build/CMakeCache.txt | head -1) + fi + if [[ -n $qt_core_dir ]]; then + qt_dir=$(dirname "$(dirname "$(dirname "$qt_core_dir")")") + fi + if [[ -z $qt_dir || ! -d $qt_dir ]]; then + # Warnings, not failures: the upload of our own binaries is what this + # step is for, and continue-on-error already means an upload failure + # cannot be told apart from a successful one. + echo "::warning::Qt runtime directory not resolved from build/CMakeCache.txt; Qt images get no unwind information" + elif [[ ! -f build/required-runtime.txt ]]; then + echo "::warning::build/required-runtime.txt is missing; cannot tell which Qt DLLs ship" + else + while IFS= read -r runtime; do + name=${runtime##*/} + if [[ $runtime == */* ]]; then + # A Qt plugin: the install root keeps it as plugins//. + # `|| true`: an unmatched glob makes ls exit non-zero, and pipefail + # turns that into a step abort. + runtime_path=$(ls "$qt_dir/plugins"/*/"$name" 2>/dev/null | head -1) || true + else + runtime_path="$qt_dir/bin/$name" + fi + if [[ -f ${runtime_path:-} ]]; then + paths+=("$runtime_path") + else + echo "::warning::shipped Qt runtime $runtime not found under $qt_dir" + fi + done < <(grep -E '^(Qt6[^/]*\.dll|Plugins/)' build/required-runtime.txt) + fi + else + # Release builds carry DWARF (-g, kept by CrashReporting.cmake); macOS keeps its + # debug info in dSYM bundles, so materialize them before uploading. + for app in cockatrice oracle servatrice; do + binary="build/$app/$app.app/Contents/MacOS/$app" + if [[ -f $binary ]]; then + if [[ '${{ matrix.os }}' == 'macOS' ]]; then + dsymutil "$binary" -o "$binary.dSYM" + paths+=("$binary.dSYM") + # The dSYM holds the debug information, but the unwind information + # Sentry needs to walk a minidump stack lives in the Mach-O itself. + paths+=("$binary") + else + paths+=("$binary") + fi + fi + done + fi + if [[ ${#paths[@]} == 0 ]]; then + # Fail loudly instead of skipping: this step is the only way symbols reach + # Sentry, and `continue-on-error` keeps the job green either way, so a + # silent skip is indistinguishable from a successful upload. It also hid a + # wrong Windows path (build//Release, which Ninja never creates). + echo "::error::No debug symbol files found; nothing would be uploaded" + exit 1 + fi + # Project slug on Sentry; must match the project the baked DSN points at. + # No --release: see the Linux leg above — the flag does not exist on + # `debug-files upload` and DIFs are matched by debug ID, not release. + sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" + # CPack reports a failed makensis run as "Problem running NSIS command" # plus a path to NSISOutput.log, and that log lives only on the runner, so # the actual compile error is otherwise nowhere in the workflow output. @@ -706,19 +888,6 @@ jobs: if-no-files-found: error path: ${{ steps.build.outputs.path }} - # Ninja is single-config: binaries and their PDBs land directly in the - # target directories instead of a Release subdirectory. - - name: "[Windows] Upload PDBs (Program Databases)" - if: matrix.os == 'Windows' && github.ref_type != 'tag' - uses: actions/upload-artifact@v7 - with: - if-no-files-found: error - name: ${{ steps.build.outputs.name }}-PDBs - path: | - build/cockatrice/*.pdb - build/oracle/*.pdb - build/servatrice/*.pdb - - name: "Upload to release" if: needs.configure.outputs.tag != null && matrix.make_package == '1' id: upload_release @@ -730,6 +899,33 @@ jobs: tag_name: ${{ needs.configure.outputs.tag }} run: gh release upload "$tag_name" "$asset_path#$asset_name" + # Ninja is single-config: binaries and their PDBs land directly in the + # target directories instead of a Release subdirectory. + # + # Archived for tag builds only, and ordered after the release upload so + # a missing PDB can never block a release from being published. On a tag + # build this artifact is the only durable copy of the symbols: the Sentry + # upload is continue-on-error, so without it a failed upload would lose + # the ability to symbolicate that release permanently. Other builds upload + # no symbols at all, so archiving them there would only cost storage. + # + # The executables travel with their PDBs: re-uploading the pair together is + # what lets sentry-cli associate the PE's code identifier with the PDB's + # debug identifier, and the PE is what carries the unwind information. + - name: "[Windows] Upload debug symbols (exe + pdb)" + if: matrix.os == 'Windows' && needs.configure.outputs.tag != null + uses: actions/upload-artifact@v7 + with: + if-no-files-found: error + name: ${{ steps.build.outputs.name }}-Symbols + path: | + build/cockatrice/*.exe + build/cockatrice/*.pdb + build/oracle/*.exe + build/oracle/*.pdb + build/servatrice/*.exe + build/servatrice/*.pdb + - name: "Attest binary provenance" if: steps.upload_release.outcome == 'success' id: attestation diff --git a/cmake/CrashReporting.cmake b/cmake/CrashReporting.cmake index abd029cc2..db016269f 100644 --- a/cmake/CrashReporting.cmake +++ b/cmake/CrashReporting.cmake @@ -46,12 +46,14 @@ if(WITH_CRASH_REPORTING AND WITH_CLIENT) if(ENABLE_SANITIZERS) # The crash handler cannot run under ASan/UBSan, so there is no backend to capture with. set(_sentry_backend "none") - elseif(WIN32 OR APPLE) - # Out-of-process handler; shipping the handler binary is a later increment, which unifies - # every OS on breakpad (in-process, no extra binary to ship). - set(_sentry_backend "crashpad") else() - # In-process, no extra binary to ship. + # Breakpad on every OS (increment 3 probe, 2026-10-14): the crashpad sources + # do vendor friction-free (FetchContent inits all submodules recursively by + # default, verified populated in a local build tree), but the crashpad backend + # would need its out-of-process crashpad_handler binary installed next to the + # exe, into the macOS bundle, and through NSIS, plus handler-path wiring and + # notarization care. Breakpad is in-process, needs no extra binary and no + # packaging on any OS sentry-native builds, so simplicity wins. set(_sentry_backend "breakpad") endif() # FORCE so toggling ENABLE_SANITIZERS on a reused build dir cannot leave a stale backend behind. @@ -61,6 +63,18 @@ if(WITH_CRASH_REPORTING AND WITH_CLIENT) ) unset(_sentry_backend) + if(NOT MSVC) + # Symbol upload needs debug info in Release binaries: the top-level flags build GCC + # Release as -s -O2 (stripped, no DWARF) and Clang/Apple Release as plain -O2 (no DWARF), + # both of which upload nothing useful. Keep -g and drop the strip flag while the SDK is + # on; the OFF default leaves local, distro and Debug builds untouched. MSVC already + # links /DEBUG with PDBs in release (see top-level CMakeLists.txt), so it needs nothing. + string(REGEX REPLACE "(^| )-s( |$)" "\\1\\2" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}") + if(NOT CMAKE_CXX_FLAGS_RELEASE MATCHES "(^| )-g( |$)") + set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g") + endif() + endif() + # No Qt integration: it runs find_package(Qt6) inside the vendored build, which # cannot see our thin/aqt Qt layouts (macOS CI) under foreign toolchains, and its # breadcrumbs could carry unredacted log lines into native crash events that bypass @@ -129,3 +143,39 @@ else() "(WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}, WITH_CLIENT=${WITH_CLIENT})" ) endif() + +# Packaged release binaries must not ship the DWARF that the symbol upload needs: it is +# 80-90% of an unstripped binary and nobody downloads a 500 MB installer to get it. The +# build tree keeps -g (CI uploads symbols from there); the *packaged copy* is split at +# install time by a CPack install script, which extracts the debug info next to the staging +# dir and strips the binary. Only ELF (Linux/BSD) needs this: Windows links /DEBUG with +# separate PDBs, and macOS keeps its dSYM extracted from the bundle. +option(COCKATRICE_SPLIT_DEBUG_SYMBOLS "Strip DWARF out of packaged release binaries" ON) +set(COCKATRICE_DEBUG_SYMBOL_DIR + "${CMAKE_BINARY_DIR}/debug-symbols" + CACHE PATH "Where the packaged binaries' split debug information is written (kept out of the package)" +) +if(WITH_CRASH_REPORTING + AND WITH_CLIENT + AND COCKATRICE_SPLIT_DEBUG_SYMBOLS + AND CMAKE_BUILD_TYPE STREQUAL "Release" + AND UNIX + AND NOT APPLE +) + find_program(COCKATRICE_OBJCOPY NAMES objcopy) + find_program(COCKATRICE_STRIP_TOOL NAMES strip) + if(COCKATRICE_OBJCOPY AND COCKATRICE_STRIP_TOOL) + configure_file( + "${CMAKE_CURRENT_LIST_DIR}/SplitDebugSymbols.cmake.in" "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake" @ONLY + ) + # PRE_BUILD runs after CPack stages the install tree and before the package is produced. + # CPACK_INSTALL_SCRIPTS would run too early (staging is still empty); the script walks + # CPACK_TEMPORARY_DIRECTORY, never CMAKE_INSTALL_PREFIX, and refuses to run otherwise. + list(APPEND CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake") + message(STATUS "Crash reporting: packaged binaries stripped, debug info split to ${COCKATRICE_DEBUG_SYMBOL_DIR}") + else() + message(WARNING "COCKATRICE_SPLIT_DEBUG_SYMBOLS is ON but objcopy/strip were not found; " + "packaged release binaries will keep their debug information" + ) + endif() +endif() diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 811a3cb7f..3647303b9 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -1530,11 +1530,22 @@ ${If} $PortableMode = 0 ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 IntFmt $0 "0x%08X" $0 - ; Enable Windows User-Mode Dumps + ; No Windows Error Reporting LocalDumps registration: crash capture belongs to + ; the in-app reporter (Sentry/breakpad, MiniDumpNormal-class data only). Opting + ; machines into OS-level full dumps (DumpType 2) would leave unredacted memory + ; dumps on user machines that nothing consumes. ; https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps - WriteRegExpandStr HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" "DumpFolder" "%LOCALAPPDATA%\CrashDumps\Cockatrice" - WriteRegDWORD HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" "DumpCount" "5" - WriteRegDWORD HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" "DumpType" "2" + + ; Older installers did register cockatrice.exe above (and the removed + ; MiniDumpWithFullMemory writer filled that folder), and that key survives this + ; install, so WER would keep writing full dumps. Remove it, and clear the + ; legacy dumps nothing consumes anymore. $LOCALAPPDATA resolves per shell context: + ; under "all" it points at ProgramData, so switch to "current" for the per-user + ; dumps folder and back afterwards; the registry delete works in either context. + DeleteRegKey HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" + SetShellVarContext current + RMDir /r "$LOCALAPPDATA\CrashDumps\Cockatrice" + SetShellVarContext all WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "DisplayIcon" "$INSTDIR\cockatrice.exe" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "DisplayName" "Cockatrice" @@ -1635,6 +1646,15 @@ Section "un.Application" UnSecApplication DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" + ; Leftover WER LocalDumps key from pre-Sentry installers (see the installer note + ; above): removing it here covers machines that uninstall without reinstalling. + ; The legacy full-memory dumps go with it; nothing consumes them anymore. Same shell + ; context note as the installer: the per-user dumps folder needs "current". + DeleteRegKey HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" + SetShellVarContext current + RMDir /r "$LOCALAPPDATA\CrashDumps\Cockatrice" + SetShellVarContext all + ; Only remove the file/protocol associations if we registered them (i.e. the ; install was not portable) and .cod is still owned by Cockatrice, so we don't ; clobber a .cod association installed by another application. diff --git a/cmake/SplitDebugSymbols.cmake.in b/cmake/SplitDebugSymbols.cmake.in new file mode 100644 index 000000000..bfe954712 --- /dev/null +++ b/cmake/SplitDebugSymbols.cmake.in @@ -0,0 +1,80 @@ +# Splits DWARF out of the packaged ELF binaries, then strips them. +# +# This is a CPack pre-build script (CPACK_PRE_BUILD_SCRIPTS, wired in CrashReporting.cmake): +# CPack runs it after staging the install tree and before producing the package, so it only +# ever touches the copy that becomes the release artifact. The build tree is left alone: -g +# stays on, and CI uploads symbols to Sentry straight from build/. +# +# For every ELF file under CPACK_TEMPORARY_DIRECTORY the script extracts the debug information +# to COCKATRICE_DEBUG_SYMBOL_DIR and removes the debug sections from the packaged binary. +# COCKATRICE_DEBUG_SYMBOL_DIR is deliberately outside the staging tree, so sidecars never end +# up in the package; CI can archive/upload them if wanted (it normally uploads straight from +# the build tree). The only thing that must survive stripping is .eh_frame (allocatable, used +# for unwinding), which neither --only-keep-debug nor --strip-debug removes. +# +# Tool paths, the symbol directory and the tree to walk are baked in / provided at configure / +# run time; the guards below refuse to run on anything that is not a CPack staging directory, +# so a missing variable can never turn the glob into a filesystem-wide walk. + +set(_objcopy "@COCKATRICE_OBJCOPY@") +set(_strip "@COCKATRICE_STRIP_TOOL@") +set(_symbolDir "@COCKATRICE_DEBUG_SYMBOL_DIR@") + +if(_objcopy STREQUAL "" OR _strip STREQUAL "") + message(WARNING "split debug symbols: objcopy/strip not found at configure time, packaging unstripped binaries") + return() +endif() +if(_symbolDir STREQUAL "") + message(FATAL_ERROR "split debug symbols: COCKATRICE_DEBUG_SYMBOL_DIR is empty") +endif() + +# CPack sets CPACK_TEMPORARY_DIRECTORY to the staging tree; CMAKE_INSTALL_PREFIX is still the +# configured /usr prefix here, so globbing it would walk system directories. Refuse to run +# unless the path exists and really is a CPack staging tree. +if(NOT CPACK_TEMPORARY_DIRECTORY OR NOT IS_DIRECTORY "${CPACK_TEMPORARY_DIRECTORY}") + message(STATUS "split debug symbols: no CPack staging directory, skipping") + return() +endif() +if(NOT CPACK_TEMPORARY_DIRECTORY MATCHES "_CPack_Packages") + message(WARNING "split debug symbols: ${CPACK_TEMPORARY_DIRECTORY} is not a CPack staging tree, skipping") + return() +endif() + +file(MAKE_DIRECTORY "${_symbolDir}") + +file( + GLOB_RECURSE _entries + LIST_DIRECTORIES true + "${CPACK_TEMPORARY_DIRECTORY}/*" +) +set(_processed 0) +foreach(_file IN LISTS _entries) + if(IS_DIRECTORY "${_file}" OR IS_SYMLINK "${_file}") + continue() + endif() + # Only ELF: objcopy would fail on the icons, translations and Qt resources next to it. + file( + READ "${_file}" _magic + OFFSET 0 + LIMIT 4 + HEX + ) + if(NOT _magic STREQUAL "7f454c46") + continue() + endif() + get_filename_component(_name "${_file}" NAME) + set(_debug "${_symbolDir}/${_name}.debug") + execute_process(COMMAND "${_objcopy}" --only-keep-debug "${_file}" "${_debug}" RESULT_VARIABLE _rc) + if(NOT _rc EQUAL 0) + message(WARNING "split debug symbols: objcopy --only-keep-debug failed for ${_file}") + continue() + endif() + execute_process(COMMAND "${_strip}" --strip-debug --strip-unneeded "${_file}" RESULT_VARIABLE _rc) + if(NOT _rc EQUAL 0) + message(WARNING "split debug symbols: strip failed for ${_file}") + continue() + endif() + math(EXPR _processed "${_processed} + 1") +endforeach() + +message(STATUS "Split debug symbols for ${_processed} packaged ELF file(s); sidecars in ${_symbolDir}") diff --git a/cockatrice/src/client/crash/crash_reporter.cpp b/cockatrice/src/client/crash/crash_reporter.cpp index 720bcd350..07a0389c7 100644 --- a/cockatrice/src/client/crash/crash_reporter.cpp +++ b/cockatrice/src/client/crash/crash_reporter.cpp @@ -5,12 +5,15 @@ #endif #include +#include #include #include #include #include +#include #include #include +#include #include #include #include @@ -263,8 +266,16 @@ void CrashReporter::init(const QString &dsn, sentry_options_set_release(options, release.toUtf8().constData()); sentry_options_set_database_path(options, sentryDatabasePath.toUtf8().constData()); // Consent-gated uploads: crashes are captured into the local database but - // nothing leaves the machine until giveConsent(). + // nothing leaves the machine until giveConsent(). cache_keep holds the previous run's + // crash envelope in /cache instead of dropping it at init, so the Ask dialog + // decides its fate: giveConsent() flushes it via the transport retry, revokeConsent() + // deletes it below. This also covers the first crash after switching to AlwaysSend, + // which giveConsent() (running after init) would otherwise miss. http_retry is required + // for the flush: without it the held envelope is written with a plain filename the retry + // scanner skips, and giveConsent()'s sentry_transport_retry() sends nothing. sentry_options_set_require_user_consent(options, 1); + sentry_options_set_cache_keep(options, SENTRY_CACHE_KEEP_OFFLINE); + sentry_options_set_http_retry(options, 1); sentry_options_set_before_send(options, scrubEventBeforeSend, nullptr); ready = (sentry_init(options) == 0); #else @@ -291,6 +302,79 @@ void CrashReporter::shutdown() ready = false; } +CrashReporter::~CrashReporter() = default; +CrashReporter::CrashReporter() = default; + +bool CrashReporter::beginSession(const QString &dir) +{ + // Restartable: releasing first keeps begin->end->begin round-trips clean. + endSession(); + + QDir().mkpath(dir); + const QDir sessionDir(dir); + const QString selfName = QStringLiteral("session-%1").arg(QCoreApplication::applicationPid()); + + bool unclean = false; + const QStringList leftovers = sessionDir.entryList({QStringLiteral("session-*")}, QDir::Files); + for (const QString &name : leftovers) { + if (name == selfName) { + continue; + } + const QString path = sessionDir.filePath(name); + QLockFile probe(path); + // Long-lived session lock: stale means "owner dead", regardless of file age, so a + // crash followed by an immediate relaunch (younger than the 30 s default) is still + // detected. tryLock() only deletes the file when it finds it stale, so a live + // concurrent instance's lock is never touched here. (The static + // QLockFile::removeStaleLockFile(path) form does not exist in Qt5/Qt6, and the + // member removeStaleLockFile() force-removes without a staleness check, so neither + // is usable for this scan.) + probe.setStaleLockTime(0); + if (probe.tryLock(0)) { + unclean = true; + probe.unlock(); // deletes the stale file + continue; + } + // tryLock failed: either a live concurrent instance owns the file, or it is + // unreadable (garbage/empty) and therefore has no live owner. Only the latter is + // cleaned up; a live instance is left alone. On Unix QLockFile creates the file + // before writing the pid, so a second instance starting at the same moment can + // briefly observe an empty (or half-written) file for a live owner: re-read once + // after a beat so a live owner's fresh lock is never mistaken for garbage and deleted. + qint64 pid = 0; + QString hostname, appname; + if (!probe.getLockInfo(&pid, &hostname, &appname)) { + QThread::msleep(100); + if (!probe.getLockInfo(&pid, &hostname, &appname)) { + QFile::remove(path); + unclean = true; + } + } + } + + sessionLockPath = sessionDir.filePath(selfName); + sessionLock = std::make_unique(sessionLockPath); + sessionLock->setStaleLockTime(0); + if (!sessionLock->tryLock(0)) { + qCWarning(CrashReportLog) << "Failed to claim session lock at" << sessionLockPath; + } + return unclean; +} + +void CrashReporter::endSession() +{ + if (sessionLock) { + // unlock() removes the lock file; nothing left to clean up. + sessionLock->unlock(); + sessionLock.reset(); + } else if (!sessionLockPath.isEmpty()) { + // The lock was never claimed (a live concurrent instance owns its file): only our + // own unclaimed file, if tryLock left one behind, is removed. + QFile::remove(sessionLockPath); + } + sessionLockPath.clear(); +} + void CrashReporter::giveConsent() { #if COCKATRICE_WITH_CRASH_REPORTING @@ -307,6 +391,10 @@ void CrashReporter::revokeConsent() // return to unknown, which blocks uploads too but loses the decision. if (ready) { sentry_user_consent_revoke(); + // Drop the held crash envelope: without this it would sit in /cache and + // upload on the next give (e.g. after switching to AlwaysSend), after the user + // explicitly refused it. The SDK recreates the directory as needed. + QDir(sentryDatabasePath + QStringLiteral("/cache")).removeRecursively(); } #endif } diff --git a/cockatrice/src/client/crash/crash_reporter.h b/cockatrice/src/client/crash/crash_reporter.h index 598b6445c..aecc6ade3 100644 --- a/cockatrice/src/client/crash/crash_reporter.h +++ b/cockatrice/src/client/crash/crash_reporter.h @@ -11,6 +11,7 @@ #include #include #include +#include #ifndef COCKATRICE_WITH_CRASH_REPORTING #define COCKATRICE_WITH_CRASH_REPORTING 0 @@ -24,31 +25,46 @@ #define COCKATRICE_SENTRY_RELEASE "" #endif +class QLockFile; + Q_DECLARE_LOGGING_CATEGORY(CrashReportLog); /** * @brief Sentry-backed crash-report facade, owned by main(). * - * Single responsibility: init/shutdown the sentry-native SDK behind consent - * gating, plus the shared log redaction helpers. An empty DSN (or Never - * consent) skips the SDK entirely, so the mock dialog fallback behaves exactly - * as before. With a DSN, init() arms require-user-consent and before_send - * scrubbing (stale persisted consent from a previous AlwaysSend run is cleared - * before init when the mode is not AlwaysSend); giveConsent()/revokeConsent() - * gate uploads. attachToPendingCrashEnvelopes() folds the redacted log and the - * optional user note into the pending native crash event(s) before consent - * releases them, so dump, stack, log and note arrive as ONE Sentry event; - * sendReport() is the fallback for unclean exits that produced no native crash - * event, capturing a redacted message event with the log preview attached. All - * SDK calls are guarded so OFF builds compile to no-ops. + * An empty DSN (or Never consent) skips the SDK entirely, so the mock dialog + * fallback behaves exactly as before. All SDK calls are guarded so OFF builds + * compile to no-ops. + * + * @par Consent gating + * init() arms require-user-consent and before_send scrubbing. Stale persisted + * consent from a previous AlwaysSend run is cleared before init when the mode is + * not AlwaysSend, and cached crash envelopes are held for the dialog instead of + * being dropped. giveConsent()/revokeConsent() gate uploads. + * + * @par Envelope and log helpers + * attachToPendingCrashEnvelopes() folds the redacted log and the optional user + * note into the pending native crash event(s) before consent releases them, so + * dump, stack, log and note arrive as ONE Sentry event. sendReport() is the + * fallback for unclean exits that produced no native crash event, capturing a + * redacted message event with the log preview attached. The log redaction + * helpers are static and SDK-independent. + * + * @par Session tracking + * beginSession()/endSession() are SDK-independent: a QLockFile per process in + * the database dir detects unclean exits cross-platform, replacing the legacy + * Windows-only .dmp scan. The held lock makes the reporter non-copyable and + * non-movable. */ class CrashReporter { public: - CrashReporter() = default; + CrashReporter(); CrashReporter(const CrashReporter &) = delete; CrashReporter &operator=(const CrashReporter &) = delete; - ~CrashReporter() = default; + CrashReporter(CrashReporter &&) = delete; + CrashReporter &operator=(CrashReporter &&) = delete; + ~CrashReporter(); void init(const QString &dsn, const QString &release, @@ -140,12 +156,22 @@ public: */ static QString describeOriginMismatch(const QString &envelopePath, const QString ¤tDsn); + /** + * @brief Claim this process's session lock, reporting a previous unclean exit. + * @return true if a leftover session lock from a dead process (or an unreadable + * garbage file) was found and cleaned; live concurrent instances are ignored. + */ + bool beginSession(const QString &dir); + void endSession(); + static QString sanitizeLogLine(const QString &line); static QString redactCockatriceUrls(const QString &line); private: bool ready = false; QString sentryDatabasePath; + QString sessionLockPath; + std::unique_ptr sessionLock; }; #endif diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index fd98b1945..7b8648d69 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -46,9 +46,6 @@ #include #include #include -#include -#include -#include #include #include #include @@ -108,60 +105,6 @@ static void CockatriceLogger(QtMsgType type, const QMessageLogContext &ctx, cons Logger::getInstance().log(type, ctx, logMessage); } -#ifdef Q_OS_WIN -// clang-format off -#include -#include -#include -#include -#include - -#pragma comment(lib, "DbgHelp.lib") // Link the DbgHelp library -// clang-format on - -LONG WINAPI CockatriceUnhandledExceptionFilter(EXCEPTION_POINTERS *exceptionPointers) -{ - std::filesystem::path path; - - // Find %LOCALAPPDATA% (or cheat at finding it) - wchar_t *localAppDataFolder; - if (SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_CREATE, NULL, &localAppDataFolder) != S_OK) { - path = std::filesystem::temp_directory_path().parent_path().parent_path(); - } else { - path = std::filesystem::path(localAppDataFolder); - } - - // Plan on writing crash files into %LOCALAPPDATA%/CrashDumps/Cockatrice/cockatrice.crash.*.dmp - path /= "CrashDumps"; - path /= "Cockatrice"; - if (!std::filesystem::exists(path)) { - std::filesystem::create_directories(path); - } - path /= "cockatrice.crash." + std::to_string(std::time(0)) + ".dmp"; - - // Create and write crash files -#ifdef UNICODE - HANDLE hDumpFile = - CreateFile(path.wstring().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); -#else - HANDLE hDumpFile = - CreateFile(path.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); -#endif - - MINIDUMP_EXCEPTION_INFORMATION mei; - mei.ExceptionPointers = exceptionPointers; - mei.ThreadId = GetCurrentThreadId(); - mei.ClientPointers = 1; - - MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hDumpFile, MiniDumpWithFullMemory, &mei, nullptr, - nullptr); - - CloseHandle(hDumpFile); - - return EXCEPTION_EXECUTE_HANDLER; -} -#endif - void installNewTranslator() { QString lang = SettingsCache::instance().personal().getLang(); @@ -202,11 +145,52 @@ static QString redactActivationUrl(const QString &url) return parsed.scheme() + "://" + parsed.host(); } +/** + * @brief Run the startup crash-report flow for an unclean exit (or --simulate-crash). + * + * With a configured DSN the SDK owns capture and upload (consent-gated); with an + * empty DSN the flow falls back to a local mock file. AlwaysSend uploads silently + * (the log was folded into the pending native envelope(s) at startup); Ask offers + * the dialog; NeverSend skips silently. + * + * @param attachedEnvelopes number of pending native envelopes that already carry the + * redacted log, so an AlwaysSend run only sends a standalone + * log event when no crash event existed. + */ +static void runCrashReportFlow(CrashReporter &crashReporter, QWidget *parent, int attachedEnvelopes) +{ + const PersonalSettings::CrashReportingMode crashMode = SettingsCache::instance().personal().getCrashReportingMode(); + const bool crashReady = crashReporter.isReady(); + if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { + if (crashReady) { + // Silent path: the redacted log was folded into the pending native crash + // envelope(s) at startup and consent already released them. Only when no + // pending crash event existed (e.g. --simulate-crash) does a standalone + // log event still need to go out here. + if (attachedEnvelopes == 0) { + const QString crashPreview = DlgCrashReport::buildRedactedPreview(); + if (!crashReporter.sendReport(crashPreview)) { + const QString savedPath = DlgCrashReport::writeMockReport(crashPreview); + qCWarning(MainLog) << "Failed to send crash report, saved locally to" << savedPath; + } + } + } else { + DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); + } + } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { + // Real mode: Send gives consent + captures the event, Don't send revokes + // consent; the dialog owns both via the reporter. + DlgCrashReport crashDialog(parent, crashReady, crashReady ? &crashReporter : nullptr); + crashDialog.exec(); + } + // NeverSend: skip silently (no dialog, nothing written or sent). +} + int main(int argc, char *argv[]) { -#ifdef Q_OS_WIN - SetUnhandledExceptionFilter(CockatriceUnhandledExceptionFilter); -#endif + // No custom unhandled-exception filter: sentry owns native crash capture on all + // platforms. (The legacy MiniDumpWithFullMemory writer was removed as a privacy + // liability; unclean exits are detected via CrashReporter::beginSession below.) // Logging setup #ifdef Q_OS_APPLE @@ -404,6 +388,16 @@ int main(int argc, char *argv[]) } } + // Unclean-exit detection (cross-platform QLockFile session tracking, replacing the + // legacy Windows-only .dmp scan): a leftover session lock means the previous run died + // without reaching endSession(). Our own lock is held until the event loop finishes. + // Deliberately placed after parser.process() (--help/--version exit inside it and must + // not leak a lock) and after the instance hand-off (a handed-off secondary exits + // immediately and must never take a lock). Note this counts any death without + // endSession() as a crash, including SIGTERM at logout, Task Manager kills and + // session-end shutdowns; in AlwaysSend those send a report too. + const bool uncleanExit = crashReporter.beginSession(crashDatabasePath); + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); themeManager = new ThemeManager; soundEngine = new SoundEngine; @@ -497,63 +491,11 @@ int main(int argc, char *argv[]) ui.show(); qCInfo(MainLog) << "ui.show() finished"; - // Crash-report flow: with a configured DSN the SDK owns capture and upload - // (consent-gated); with an empty DSN the dialog falls back to a local mock - // file and behaves exactly as before. Triggered by --simulate-crash or by - // leftover Windows crash dumps, which are consumed after handling so one - // crash never prompts twice. - { - QString crashDumpDir; -#ifdef Q_OS_WIN - // Mirrors the writer (CockatriceUnhandledExceptionFilter): it appends CrashDumps/Cockatrice - // to %LOCALAPPDATA%, or, when its known-folder lookup fails, to the temp directory walked up - // two parent_path() steps. %LOCALAPPDATA% is this scan's stand-in for FOLDERID_LocalAppData: - // both come from the same profile, so when the lookup fails the variable is normally unset. - QString crashDumpRootPath = QString::fromLocal8Bit(qgetenv("LOCALAPPDATA")); - if (crashDumpRootPath.isEmpty()) { - // The writer's fallback expression, verbatim — QDir::cdUp() is existence-checked and - // would silently leave the path alone here, parent_path() is not. - const std::filesystem::path fallback = std::filesystem::temp_directory_path().parent_path().parent_path(); - crashDumpRootPath = QString::fromStdWString(fallback.wstring()); - } - crashDumpDir = QDir(crashDumpRootPath).absoluteFilePath(QStringLiteral("CrashDumps/Cockatrice")); -#else - crashDumpDir = QDir::tempPath(); -#endif - const QStringList crashDumps = - QDir(crashDumpDir).entryList({QStringLiteral("cockatrice.crash.*.dmp")}, QDir::Files); - if (parser.isSet("simulate-crash") || !crashDumps.isEmpty()) { - const PersonalSettings::CrashReportingMode crashMode = - SettingsCache::instance().personal().getCrashReportingMode(); - const bool crashReady = crashReporter.isReady(); - if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { - if (crashReady) { - // Silent path: the redacted log was folded into the pending native crash - // envelope(s) at startup and consent already released them. Only when no - // pending crash event existed (e.g. --simulate-crash) does a standalone - // log event still need to go out here. - if (crashLogAttachedEnvelopes == 0) { - const QString crashPreview = DlgCrashReport::buildRedactedPreview(); - if (!crashReporter.sendReport(crashPreview)) { - const QString savedPath = DlgCrashReport::writeMockReport(crashPreview); - qCWarning(MainLog) << "Failed to send crash report, saved locally to" << savedPath; - } - } - } else { - DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); - } - } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { - // Real mode: Send gives consent + captures the event, Don't - // send revokes consent; the dialog owns both via the reporter. - DlgCrashReport crashDialog(&ui, crashReady, crashReady ? &crashReporter : nullptr); - crashDialog.exec(); - } - // NeverSend: skip silently (no dialog, nothing written or sent). - // Handled dumps are consumed so one crash never prompts twice. - for (const QString &dump : crashDumps) { - QFile::remove(crashDumpDir + QStringLiteral("/") + dump); - } - } + // Crash-report flow: the trigger is --simulate-crash or an unclean previous exit + // (leftover session lock; the old %LOCALAPPDATA%/CrashDumps .dmp scan is gone with + // the exception filter). See runCrashReportFlow() for the per-mode handling. + if (parser.isSet("simulate-crash") || uncleanExit) { + runCrashReportFlow(crashReporter, &ui, crashLogAttachedEnvelopes); } // force shortcuts to be shown/hidden in right-click menus, regardless of system defaults @@ -580,8 +522,6 @@ int main(int argc, char *argv[]) int ret = app.exec(); - crashReporter.shutdown(); - qCInfo(MainLog) << "Event loop finished, terminating..."; delete rng; PingPixmapGenerator::clear(); @@ -589,5 +529,10 @@ int main(int argc, char *argv[]) UserLevelPixmapGenerator::clear(); ManaSymbolPixmapGenerator::clear(); + // Released last: crashes anywhere in the teardown above (a common place for Qt apps to + // crash) are still captured by the SDK and detected as unclean exits on the next launch. + crashReporter.shutdown(); + crashReporter.endSession(); + return ret; } diff --git a/tests/crash_reporter_test.cpp b/tests/crash_reporter_test.cpp index e15007f7a..e44a34a60 100644 --- a/tests/crash_reporter_test.cpp +++ b/tests/crash_reporter_test.cpp @@ -1,10 +1,14 @@ #include "client/crash/crash_reporter.h" #include +#include #include #include +#include #include +#include #include +#include #include #include #include @@ -339,6 +343,81 @@ TEST(CrashReporterTest, AttachSkipsNoteWhenEmptyAndFindsRunDirEnvelopes) ASSERT_FALSE(content.contains(QByteArrayLiteral("user-note.txt"))); } +TEST(CrashReporterTest, BeginSessionOnEmptyDirReportsClean) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + CrashReporter reporter; + ASSERT_FALSE(reporter.beginSession(dir.path())); + reporter.endSession(); +} + +TEST(CrashReporterTest, BeginSessionIgnoresLiveConcurrentInstance) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // A second QLockFile held in-process stands in for a live concurrent instance: same + // pid, alive, so the scan must leave it alone and report a clean start. + QLockFile liveInstance(dir.filePath(QStringLiteral("session-424242"))); + ASSERT_TRUE(liveInstance.tryLock(0)); + CrashReporter reporter; + ASSERT_FALSE(reporter.beginSession(dir.path())); + ASSERT_TRUE(QFile::exists(liveInstance.fileName())); + reporter.endSession(); + liveInstance.unlock(); +} + +TEST(CrashReporterTest, BeginSessionDetectsStaleDeadOwnerLock) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // A well-formed lock whose owner is dead: our own pid (so the host matches) but a + // process name that cannot match, which is how QLockFile spots pid reuse. Must be + // reported unclean and cleaned up, even though the file is brand new (this is why + // the scan probes with staleLockTime 0 rather than the 30 s default). + const QString stalePath = dir.filePath(QStringLiteral("session-stale-owner")); + QFile stale(stalePath); + ASSERT_TRUE(stale.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&stale); + out << QCoreApplication::applicationPid() << '\n' + << QStringLiteral("definitely-not-the-test-binary") << '\n' + << QSysInfo::machineHostName() << '\n'; + stale.close(); + + CrashReporter reporter; + ASSERT_TRUE(reporter.beginSession(dir.path())); + ASSERT_FALSE(QFile::exists(stalePath)); + reporter.endSession(); +} + +TEST(CrashReporterTest, BeginSessionCleansGarbageFileAndReportsUnclean) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // An unreadable lock file has no live owner by definition: report unclean and remove it. + const QString garbagePath = dir.filePath(QStringLiteral("session-garbage")); + QFile garbage(garbagePath); + ASSERT_TRUE(garbage.open(QIODevice::WriteOnly)); + garbage.write("not-a-lock-file\x00\xff", 17); + garbage.close(); + + CrashReporter reporter; + ASSERT_TRUE(reporter.beginSession(dir.path())); + ASSERT_FALSE(QFile::exists(garbagePath)); + reporter.endSession(); +} + +TEST(CrashReporterTest, BeginSessionEndSessionRoundTripReportsClean) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + CrashReporter reporter; + ASSERT_FALSE(reporter.beginSession(dir.path())); + reporter.endSession(); + ASSERT_FALSE(reporter.beginSession(dir.path())); + reporter.endSession(); +} + } // namespace int main(int argc, char **argv) From c6f682f9cf43996a4e6ce141dae5b078d97dac9b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:57:04 +0200 Subject: [PATCH 086/247] [CrashReporting] Offer to send the installer log on abort (#7524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CrashReporting] Installer log send offer on abort (inc 4) DSN parsed to CPACK_SENTRY_* (scheme-aware for http self-hosts) before include(CPack); new SendInstallerLog.ps1.in builds a byte-accurate Sentry envelope (128 KiB tail cap, X-Sentry-Auth, exit 0/1) with the abort reason read from the log file itself, so the NSIS side needs no string functions and quoting stays trivial. AbortInstall asks consent (attended installs only, silent path untouched, abort never swallowed). Empty/malformed DSN disables the offer; no secrets in repo. * [CrashReporting] Embed installer-log sender via a relative NSIS path The AbortInstall hook embedded SendInstallerLog.ps1 through the absolute CPACK_SENTRY_PS1_PATH, which carries CMake's forward slashes (D:/a/...). makensis does not treat a forward-slash drive path as absolute, so it resolved it relative to project.nsi, found nothing and aborted packaging on the Windows jobs of every branch in the stack. Embed it with the same '..\..\..\' relative path that already resolves NSIS.definitions.nsh: both are configure_file'd into the build root, three levels up from where CPack places project.nsi. Drop the now-unused CPACK_SENTRY_PS1_PATH cache variable. * [CrashReporting] Drop the installer /CRASHTEST hook, tidy the log sender Remove the /CRASHTEST switch and the matching COCKATRICE_SENTRY_DSN environment override on the installer abort path. With no deliberate in-installer trigger left, a package built without a baked DSN must never offer to post a user's log to an endpoint chosen by whatever happens to be in the environment, so the prompt is now gated on CPACK_SENTRY_ENABLED alone. This also retires the comment that claimed /S /R /CRASHTEST reaches the prompt: the switch was evaluated before /R set ReinstallMode, so that form never showed it. Alongside: correct the CPACK_SENTRY_RELEASE comment (PROJECT_VERSION keeps its "-

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


@@ -83,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p ### Code -To contribute code to the project, please review our [guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
+To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
We maintain two tags for contributors to easier find issues to potentially work on: - [![Good first issue](https://img.shields.io/github/issues/cockatrice/cockatrice/Good%20First%20Issue)](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on. @@ -95,7 +96,7 @@ We'll happily advice on how best to implement a feature, or we can show you wher You can also have a look at our `Todo List` in our [Code Documentation](https://cockatrice.github.io/docs) or search the repo for [`\todo` comments](https://github.com/search?q=repo%3ACockatrice%2FCockatrice%20%5Ctodo&type=code). -### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Apush) +### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Arelease+event%3Aworkflow_dispatch) There are various places where useful information for different needs are maintained: - [Official Code Documentation](https://cockatrice.github.io/docs/) @@ -104,7 +105,8 @@ There are various places where useful information for different needs are mainta - [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file` - [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) -Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage you to improve the documentation by suggesting edits based on this guide. +Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. +We encourage everybody to improve the documentation by suggesting edits based on this guide. ### Translation [![Transifex Project](https://img.shields.io/badge/translate-on%20transifex-brightgreen)](https://explore.transifex.com/cockatrice/cockatrice/) From e666a4978d706bf70e7e24e41c41816c352cf369 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:33:29 +0200 Subject: [PATCH 092/247] [VisualDatabase] Use scoped QSignalBlockers for filter updates (#7641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VisualDatabase] Use scoped QSignalBlockers for filter updates updateFilterModel() blocked signals on the filter model and its filter tree,\nthen unblocked each of them twice. The second pair was a no-op only because\nthe first pair had already restored the default. Use two scoped\nQSignalBlockers instead, so the models are unblocked exactly once before the\nexplicit changed()/layoutChanged() notifications.\n\nVerification: cockatrice built. * Drop stale typo note from QSignalBlocker comment --------- Co-authored-by: Lukas Brübach --- ...al_database_display_name_filter_widget.cpp | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp index d3e497b21..3e4e8bcc0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -139,20 +140,19 @@ void VisualDatabaseDisplayNameFilterWidget::updateFilterModel() emit filterModel->layoutAboutToBeChanged(); filterModel->clearFiltersOfType(CardFilter::Attr::AttrNameExact); - filterModel->blockSignals(true); - filterModel->filterTree()->blockSignals(true); + { + // Scope the blockers so both models are unblocked before the explicit + // change notifications below. + const QSignalBlocker modelBlocker(filterModel); + const QSignalBlocker treeBlocker(filterModel->filterTree()); - for (const auto &name : activeFilters.keys()) { - QString nameString = name; - filterModel->addFilter(new CardFilter(nameString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrNameExact)); + for (const auto &name : activeFilters.keys()) { + QString nameString = name; + filterModel->addFilter( + new CardFilter(nameString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrNameExact)); + } } - filterModel->blockSignals(false); - filterModel->filterTree()->blockSignals(false); - - filterModel->blockSignals(false); - filterModel->filterTree()->blockSignals(false); - emit filterModel->filterTree()->changed(); emit filterModel->layoutChanged(); } From 4980f88a7a64dc7d2da7457685b5618bc444607f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:34:13 +0200 Subject: [PATCH 093/247] [CardDB] Sort power/toughness without splitting every comparison (#7607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Sort power/toughness without splitting every comparison When the card database is sorted by the P/T column, lessThan() runs O(n log n) times over the whole view, and each call split both strings into a QStringList ("power/toughness") and then read the two elements back. For the roughly 36k cards in the database that is millions of list and string allocations, all for a value that contains at most one separator. The comparator now locates the single '/' with indexOf and passes the two halves as QStringView to lessThanNumerically(), which is changed from QString to QStringView. The numeric parsing, the "1+*" fallback and the locale-aware tie-break are unchanged, and strings with no separator or more than one still fall through to the plain string comparison, exactly as split()-based size checks did before. lessThanNumerically() also initializes its two bool out-parameters; they were declared uninitialized, which only worked because toFloat() always assigns them. Compared old and new comparator over a corpus of 2024 power/toughness strings (4096576 ordered pairs, including malformed values): zero differences. Full build and ctest (628 tests) pass. * Extract well-formedness checks in P/T comparator --------- Co-authored-by: Lukas Brübach --- .../database/card_database_display_model.cpp | 49 ++++++++++--------- .../database/card_database_display_model.h | 3 +- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 39bb8314c..c333f5506 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -82,13 +82,12 @@ int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { - - QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); - QString rightString = sourceModel()->data(right, CardDatabaseModel::SortRole).toString(); + const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); + const QString rightString = sourceModel()->data(right, CardDatabaseModel::SortRole).toString(); if (!cardName.isEmpty() && left.column() == CardDatabaseModel::NameColumn) { - bool isLeftType = leftString.startsWith(cardName, Qt::CaseInsensitive); - bool isRightType = rightString.startsWith(cardName, Qt::CaseInsensitive); + const bool isLeftType = leftString.startsWith(cardName, Qt::CaseInsensitive); + const bool isRightType = rightString.startsWith(cardName, Qt::CaseInsensitive); // test for an exact match: isLeftType && leftString.size() == cardName.size() // or an exclusive start match: isLeftType && !isRightType @@ -101,31 +100,37 @@ bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelInd return false; } } else if (right.column() == CardDatabaseModel::PTColumn && left.column() == CardDatabaseModel::PTColumn) { - QStringList leftList = leftString.split("/"); - QStringList rightList = rightString.split("/"); + // The comparator runs O(n log n) times while the view is sorted, so + // splitting every power/toughness string into a QStringList allocates + // a lot for data that has exactly one separator. Work on views and + // only split the two halves out when both strings are well formed. + const QStringView leftPT = leftString; + const QStringView rightPT = rightString; + const qsizetype leftSlash = leftPT.indexOf(u'/'); + const qsizetype rightSlash = rightPT.indexOf(u'/'); + const bool leftIsWellFormed = leftSlash >= 0 && leftPT.indexOf(u'/', leftSlash + 1) < 0; + const bool rightIsWellFormed = rightSlash >= 0 && rightPT.indexOf(u'/', rightSlash + 1) < 0; - if (leftList.size() == 2 && rightList.size() == 2) { - - // cool, have both P/T in list now - int lessThanNum = lessThanNumerically(leftList.at(0), rightList.at(0)); - if (lessThanNum != 0) { - return lessThanNum < 0; - } else { - // power equal, check toughness - return lessThanNumerically(leftList.at(1), rightList.at(1)) < 0; + if (leftIsWellFormed && rightIsWellFormed) { + const int powerComparison = lessThanNumerically(leftPT.left(leftSlash), rightPT.left(rightSlash)); + if (powerComparison != 0) { + return powerComparison < 0; } + // power equal, check toughness + return lessThanNumerically(leftPT.mid(leftSlash + 1), rightPT.mid(rightSlash + 1)) < 0; } } return QString::localeAwareCompare(leftString, rightString) < 0; } -int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QString &right) +int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) { if (left == right) { return 0; } - bool okLeft, okRight; + bool okLeft = false; + bool okRight = false; float leftNum = left.toFloat(&okLeft); float rightNum = right.toFloat(&okRight); @@ -139,11 +144,11 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt } } // try and parsing again, for weird ones like "1+*" - QString leftAfterNum = ""; - QString rightAfterNum = ""; + QStringView leftAfterNum; + QStringView rightAfterNum; if (!okLeft) { int leftNumIndex = 0; - for (; leftNumIndex < left.length(); leftNumIndex++) { + for (; leftNumIndex < left.size(); leftNumIndex++) { if (!left.at(leftNumIndex).isDigit()) { break; } @@ -155,7 +160,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt } if (!okRight) { int rightNumIndex = 0; - for (; rightNumIndex < right.length(); rightNumIndex++) { + for (; rightNumIndex < right.size(); rightNumIndex++) { if (!right.at(rightNumIndex).isDigit()) { break; } diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index d12dd1fb4..77152a390 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -88,7 +89,7 @@ signals: protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; - static int lessThanNumerically(const QString &left, const QString &right); + static int lessThanNumerically(QStringView left, QStringView right); [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; [[nodiscard]] bool rowMatchesCardName(CardInfoPtr info) const; From f4b4365464cce72b69d929d318e49025753f5a53 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:23:04 +0200 Subject: [PATCH 094/247] [Search] Stop allocating per card in card-name completion (#7604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Search] Stop allocating per card in card-name completion CardSearchModel::updateSearchResults() runs on every textEdited() signal, walks the whole card database and runs Levenshtein against every matching name. Three costs dominated that path: 1. Every candidate built a QStringList of its searchable names, then lowercased each name into another QString, only to break out of the loop after the first hit. 2. The sort comparator called sortableName() for each comparison, which re-fetched the name (QMap lookup plus QString construction) O(n log n) times. 3. levenshteinDistance() allocated a full len1 x len2 vector of vectors. The search model now lowercases the one name it tests directly, stores the tie-breaking name length once per result, and the comparator only compares integers. levenshteinDistance() uses the standard two-row formulation: same result, O(len2) memory instead of O(len1*len2), with early exits for empty and equal strings. FilterString query closures get the same treatment: - Set queries iterate the set map directly instead of materializing keys()/values() lists for every card. - Rarity queries iterate printings in place instead of copying them into a QList first. - Power/toughness split the string with indexOf and compare QStringViews instead of allocating a QStringList. - Field queries do one hash lookup instead of contains() followed by value(). - Color queries bind the colors string by reference instead of copying. Verified with filter_string_test (42 tests) and a full client build. * [Search] Address review comments - Use std::any_of/std::none_of over the set map's key/value iterators instead of hand-rolled loops. - Extract the name matching into matchingLowercaseName() returning std::optional. - Reuse the matched name's length for the distance tie-break instead of looking the name up again, removing one getLocalizedName() call per card. - Take the color strings by value; the getters already return by value. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/filters/filter_string.cpp | 64 ++++++++++-------- .../database/card/card_search_model.cpp | 66 ++++++++++--------- .../models/database/card/card_search_model.h | 18 +++-- .../libcockatrice/utility/levenshtein.cpp | 41 ++++++++---- 4 files changed, 113 insertions(+), 76 deletions(-) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 0acc0eca0..081ada824 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -161,30 +162,28 @@ static void setupParserRules() if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); + const auto &sets = x->getSets(); + return std::any_of(sets.keyValueBegin(), sets.keyValueEnd(), + [&matcher](const auto &set) { return matcher(set.first); }); }; } if (sv.choice() == 1) { auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::none_of(sets.begin(), sets.end(), matchesSet); + const auto &sets = x->getSets(); + return std::none_of(sets.keyValueBegin(), sets.keyValueEnd(), + [&matcher](const auto &set) { return matcher(set.first); }); }; } auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - const auto &sets = x->getSets().values(); - auto matchesSet = [&](const PrintingInfo &printing) { - return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); - }; - return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { - return std::any_of(printings.begin(), printings.end(), matchesSet); + const auto &sets = x->getSets(); + return std::any_of(sets.constBegin(), sets.constEnd(), [&matcher](const auto &printings) { + return std::any_of(printings.begin(), printings.end(), [&matcher](const PrintingInfo &printing) { + const CardSetPtr set = printing.getSet(); + return set && set->getEnabled() && matcher(set->getReleaseDate().toJulianDay()); + }); }); }; }; @@ -207,15 +206,14 @@ static void setupParserRules() search["RarityQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto rarity = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList infos; for (const auto &printings : x->getSets()) { for (const auto &printing : printings) { - infos.append(printing); + if (rarity == printing.getProperty("rarity")) { + return true; + } } } - - auto matchesRarity = [&rarity](const PrintingInfo &info) { return rarity == info.getProperty("rarity"); }; - return std::any_of(infos.begin(), infos.end(), matchesRarity); + return false; }; }; @@ -411,7 +409,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); if (parts.contains("m") && match.length() < 2) { return false; } @@ -429,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); if (parts.contains("m") && match.length() < 2) { return false; } @@ -455,25 +453,37 @@ static void setupParserRules() }; search["PowerQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { return matcher(x->getPowTough().split("/")[0].toInt()); }; + return [=](const CardData &x) -> bool { + const QString &powTough = x->getPowTough(); + const qsizetype slash = powTough.indexOf('/'); + return matcher(slash < 0 ? powTough.toInt() : QStringView(powTough).left(slash).toInt()); + }; }; search["ToughnessQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - auto parts = x->getPowTough().split("/"); - return matcher(parts.length() == 2 ? parts[1].toInt() : 0); + const QString &powTough = x->getPowTough(); + const qsizetype slash = powTough.indexOf('/'); + return matcher(slash < 0 ? 0 : QStringView(powTough).mid(slash + 1).toInt()); }; }; search["FieldQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto field = std::any_cast(sv[0]); if (sv.choice() == 0) { const auto matcher = std::any_cast(sv[1]); - return [=](const CardData &x) -> bool { return x->hasProperty(field) && matcher(x->getProperty(field)); }; + return [=](const CardData &x) -> bool { + const auto &properties = x->getPropertiesHash(); + const auto it = properties.constFind(field); + return it != properties.constEnd() && matcher(it.value()); + }; } const auto matcher = std::any_cast(sv[1]); - return - [=](const CardData &x) -> bool { return x->hasProperty(field) && matcher(x->getProperty(field).toInt()); }; + return [=](const CardData &x) -> bool { + const auto &properties = x->getPropertiesHash(); + const auto it = properties.constFind(field); + return it != properties.constEnd() && matcher(it.value().toInt()); + }; }; search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp index 5bde6e3fc..8a267ed7f 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp @@ -9,6 +9,7 @@ #include #include #include +#include CardSearchModel::CardSearchModel(CardDatabaseDisplayModel *sourceModel, QObject *parent) : QAbstractListModel(parent), sourceModel(sourceModel) @@ -69,31 +70,28 @@ void CardSearchModel::updateSearchResults(const QString &query) continue; } - // The completer suggestions match against the same languages the card - // search uses, so typing a localized name finds the card. In Both mode - // either language can match. - for (const QString &matchName : searchableNames(card)) { - const QString lowerName = matchName.toLower(); - if (!lowerName.contains(lowerQuery)) { - continue; - } + const std::optional lowerName = matchingLowercaseName(card, lowerQuery); + if (!lowerName) { + continue; + } - const int distance = levenshteinDistance(lowerQuery, lowerName); + const int distance = levenshteinDistance(lowerQuery, *lowerName); + // Length of the matched name, used to break distance ties. Stored here + // so the comparator does not look the name up again for every comparison. + const int nameLength = static_cast(lowerName->size()); - if (lowerName.startsWith(lowerQuery)) { - prefixMatches.append({card, distance}); - } else { - containsMatches.append({card, distance}); - } - break; + if (lowerName->startsWith(lowerQuery)) { + prefixMatches.append({card, distance, nameLength}); + } else { + containsMatches.append({card, distance, nameLength}); } } - auto sortByDistanceThenLength = [this](const SearchResult &a, const SearchResult &b) { + auto sortByDistanceThenLength = [](const SearchResult &a, const SearchResult &b) { if (a.distance != b.distance) { return a.distance < b.distance; } - return sortableName(a.card).size() < sortableName(b.card).size(); + return a.nameLength < b.nameLength; }; std::sort(prefixMatches.begin(), prefixMatches.end(), sortByDistanceThenLength); @@ -112,24 +110,28 @@ void CardSearchModel::updateSearchResults(const QString &query) endResetModel(); } -QStringList CardSearchModel::searchableNames(const CardInfoPtr &card) const +std::optional CardSearchModel::matchingLowercaseName(const CardInfoPtr &card, const QString &lowerQuery) const { + if (!searchLanguage.isEnglishOnly() && searchLanguage.mode == SearchLanguageMode::Selected) { + QString localizedName = card->getLocalizedName(searchLanguage.language).toLower(); + if (localizedName.contains(lowerQuery)) { + return std::optional(std::move(localizedName)); + } + return std::nullopt; + } + + QString englishName = card->getName().toLower(); + if (englishName.contains(lowerQuery)) { + return std::optional(std::move(englishName)); + } + if (searchLanguage.isEnglishOnly()) { - return {card->getName()}; + return std::nullopt; } - const QString localizedName = card->getLocalizedName(searchLanguage.language); - if (searchLanguage.mode == SearchLanguageMode::Selected) { - return {localizedName}; + QString localizedName = card->getLocalizedName(searchLanguage.language).toLower(); + if (localizedName.contains(lowerQuery)) { + return std::optional(std::move(localizedName)); } - - return {card->getName(), localizedName}; -} - -QString CardSearchModel::sortableName(const CardInfoPtr &card) const -{ - if (searchLanguage.isEnglishOnly()) { - return card->getName(); - } - return card->getLocalizedName(searchLanguage.language); + return std::nullopt; } diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h index 08d0e8636..ec7c1195e 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -51,13 +52,20 @@ private: { CardInfoPtr card; int distance; + int nameLength; }; - /** @brief The names a card is searched by with the current search language. */ - [[nodiscard]] QStringList searchableNames(const CardInfoPtr &card) const; - - /** @brief The name used to break distance ties when sorting suggestions. */ - [[nodiscard]] QString sortableName(const CardInfoPtr &card) const; + /** + * @brief Finds the lowercased card name that matches the query. + * + * The completer suggestions match against the same languages the card + * search uses, so typing a localized name finds the card. In Both mode + * either language can match. + * + * @return The matched lowercased name, or std::nullopt when the card does not match. + */ + [[nodiscard]] std::optional matchingLowercaseName(const CardInfoPtr &card, + const QString &lowerQuery) const; CardDatabaseDisplayModel *sourceModel; QList searchResults; diff --git a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp index f7d8459cb..11eeef19b 100644 --- a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp +++ b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp @@ -7,23 +7,40 @@ int levenshteinDistance(const QString &s1, const QString &s2) { - int len1 = s1.size(); - int len2 = s2.size(); - std::vector> dp(len1 + 1, std::vector(len2 + 1)); + const qsizetype len1 = s1.size(); + const qsizetype len2 = s2.size(); - for (int i = 0; i <= len1; i++) { - dp[i][0] = i; + if (len1 == 0) { + return static_cast(len2); } - for (int j = 0; j <= len2; j++) { - dp[0][j] = j; + if (len2 == 0) { + return static_cast(len1); + } + if (s1 == s2) { + return 0; } - for (int i = 1; i <= len1; i++) { - for (int j = 1; j <= len2; j++) { - int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; - dp[i][j] = std::min({dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost}); + // Only the previous row of the distance matrix is needed to compute the + // next one, so two rows replace the full len1 x len2 matrix. The card + // search runs this for every matching card on each keystroke. + std::vector previous(len2 + 1); + std::vector current(len2 + 1); + + for (qsizetype j = 0; j <= len2; j++) { + previous[j] = static_cast(j); + } + + for (qsizetype i = 1; i <= len1; i++) { + current[0] = static_cast(i); + const QChar c1 = s1.at(i - 1); + + for (qsizetype j = 1; j <= len2; j++) { + const int cost = (c1 == s2.at(j - 1)) ? 0 : 1; + current[j] = std::min(previous[j] + 1, std::min(current[j - 1] + 1, previous[j - 1] + cost)); } + + std::swap(previous, current); } - return dp[len1][len2]; + return previous[len2]; } From b85436461eaab487fbd3758be6dcff4d604deb98 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:23:17 +0200 Subject: [PATCH 095/247] [Filters] Precompute term data instead of redoing it per card (#7603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Filters] Precompute term data instead of redoing it per card Card filters are evaluated once per card in the database, and several attributes derived constant values from the filter term on every single evaluation: - acceptColor() ran up to twelve case-insensitive string replacements and a space strip for each card, then called getColors() again for every character of the term. - acceptRarity() ran the rarity abbreviation expansion loop per card. - acceptManaCost() uppercased and sorted the term per card, before sorting the card's own cost. - acceptLoyalty() and acceptFormat() trimmed/lowercased the term per card. - relationCheck() re-parsed the numeric operator and operand per card, for every cmc/power/toughness/loyalty comparison. The term of a FilterItem never changes, so these values are now computed once in the constructor and only the card-dependent part remains in the accept path. For colors this also removes the repeat getColors() call, which materializes the printing properties hash. Additionally: - CardInfoPtr is passed by const reference through FilterItemList, FilterItem and FilterTree instead of by value, removing an atomic refcount pair per card per filter node. - Main/sub type filtering no longer allocates a QStringList via split(); it looks the " - " separator up directly and compares QStringViews. - acceptSet() and acceptPowerToughness() fetch the printing/set or the power/toughness string once instead of once per branch/accessor. No filtering behavior is intended to change. Invalid numeric terms (for example a bare "<") now return false instead of indexing trimmedTerm[1] past the end. Verified with filter_string_test (42 tests) and a full client build. * [Filters] Address review comments - Replace the main/sub type separator accessor function with a plain namespace-scope constant. - Take card type, colors, cmc, loyalty, power/toughness and the printing's set by value; those getters already return by value. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/filters/filter_tree.cpp | 355 ++++++++++-------- .../libcockatrice/filters/filter_tree.h | 69 ++-- 2 files changed, 250 insertions(+), 174 deletions(-) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 742b44e97..f12f87181 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -9,9 +9,16 @@ #include #include #include +#include #include #include +namespace +{ +/** Separator between the main and sub types of a card type ("Creature — Elf"). */ +const QString MAIN_SUB_TYPE_SEPARATOR = QStringLiteral(" — "); +} // namespace + template FilterTreeNode *FilterTreeBranch::nodeAt(int i) const { return (childNodes.size() > i) ? childNodes.at(i) : nullptr; @@ -108,7 +115,7 @@ FilterTreeNode *FilterItemList::termNode(const QString &term) return childNodes.at(i); } -bool FilterItemList::testTypeAnd(const CardInfoPtr info, +bool FilterItemList::testTypeAnd(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -125,7 +132,7 @@ bool FilterItemList::testTypeAnd(const CardInfoPtr info, return true; } -bool FilterItemList::testTypeAndNot(const CardInfoPtr info, +bool FilterItemList::testTypeAndNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -133,7 +140,7 @@ bool FilterItemList::testTypeAndNot(const CardInfoPtr info, return !testTypeOr(info, attr, searchLanguage); } -bool FilterItemList::testTypeOr(const CardInfoPtr info, +bool FilterItemList::testTypeOr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -156,7 +163,7 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, return noChildEnabledChild; } -bool FilterItemList::testTypeOrNot(const CardInfoPtr info, +bool FilterItemList::testTypeOrNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -164,7 +171,118 @@ bool FilterItemList::testTypeOrNot(const CardInfoPtr info, return !testTypeAnd(info, attr, searchLanguage); } -bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(std::move(trm)) +{ + lowercaseTerm = term.toLower(); + + switch (attr()) { + case CardFilter::AttrColor: { + QString converted = term.trimmed(); + + converted.replace("green", "g", Qt::CaseInsensitive); + converted.replace("grn", "g", Qt::CaseInsensitive); + converted.replace("blue", "u", Qt::CaseInsensitive); + converted.replace("blu", "u", Qt::CaseInsensitive); + converted.replace("black", "b", Qt::CaseInsensitive); + converted.replace("blk", "b", Qt::CaseInsensitive); + converted.replace("red", "r", Qt::CaseInsensitive); + converted.replace("white", "w", Qt::CaseInsensitive); + converted.replace("wht", "w", Qt::CaseInsensitive); + converted.replace("colorless", "c", Qt::CaseInsensitive); + converted.replace("colourless", "c", Qt::CaseInsensitive); + converted.replace("none", "c", Qt::CaseInsensitive); + + converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + + convertedColorTerm = converted; + break; + } + case CardFilter::AttrRarity: { + QString converted = term.trimmed(); + + /* + * The purpose of this loop is to only apply one of the replacement + * policies and then escape. If we attempt to layer them on top of + * each other, we will get awkward results (i.e. comythic rare mythic rareon) + * Conditional statement will exit once a case is successful in + * replacement OR we go through all possible cases. + * Will also need to replace just "mythic" + */ + for (int i = 0; converted.length() <= 3 && i <= 6; i++) { + switch (i) { + case 0: + converted.replace("mr", "mythic", Qt::CaseInsensitive); + break; + case 1: + converted.replace("m r", "mythic", Qt::CaseInsensitive); + break; + case 2: + converted.replace("m", "mythic", Qt::CaseInsensitive); + break; + case 3: + converted.replace("c", "common", Qt::CaseInsensitive); + break; + case 4: + converted.replace("u", "uncommon", Qt::CaseInsensitive); + break; + case 5: + converted.replace("r", "rare", Qt::CaseInsensitive); + break; + case 6: + converted.replace("s", "special", Qt::CaseInsensitive); + break; + default: + break; + } + } + + convertedRarityTerm = converted; + break; + } + case CardFilter::AttrManaCost: + sortedManaCostTerm = term.toUpper(); + // Sort the mana cost so it will be easy to find + std::sort(sortedManaCostTerm.begin(), sortedManaCostTerm.end()); + break; + case CardFilter::AttrLoyalty: + uppercaseLoyaltyTerm = term.trimmed().toUpper(); + break; + default: + break; + } + + // Parse the numeric relation used by cmc, power, toughness and loyalty once. + // If the term is not numeric, it probably starts with an operator. + const QString trimmedTerm = term.trimmed(); + bool converted = false; + const int value = trimmedTerm.toInt(&converted); + if (converted) { + numericOperator = NumericOperator::Equal; + numericOperand = value; + } else if (trimmedTerm.size() > 1) { + if (trimmedTerm.at(1) == '=') { + numericOperand = trimmedTerm.mid(2).toInt(); + if (trimmedTerm.startsWith('<')) { + numericOperator = NumericOperator::LessEqual; + } else if (trimmedTerm.startsWith('>')) { + numericOperator = NumericOperator::GreaterEqual; + } else { + numericOperator = NumericOperator::Equal; + } + } else { + numericOperand = trimmedTerm.mid(1).toInt(); + if (trimmedTerm.startsWith('<')) { + numericOperator = NumericOperator::Less; + } else if (trimmedTerm.startsWith('>')) { + numericOperator = NumericOperator::Greater; + } else if (trimmedTerm.startsWith('=')) { + numericOperator = NumericOperator::Equal; + } + } + } +} + +bool FilterItem::acceptName(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishName = info->getName(); const QString &localizedName = info->getLocalizedName(searchLanguage.language); @@ -187,7 +305,7 @@ bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &se return false; } -bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterItem::acceptNameExact(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishName = info->getName(); const QString &localizedName = info->getLocalizedName(searchLanguage.language); @@ -210,44 +328,42 @@ bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguag return false; } -bool FilterItem::acceptType(const CardInfoPtr info) const +bool FilterItem::acceptType(const CardInfoPtr &info) const { return info->getCardType().contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptMainType(const CardInfoPtr info) const +bool FilterItem::acceptMainType(const CardInfoPtr &info) const { - const QStringList typeParts = info->getCardType().split(" — "); - return !typeParts.isEmpty() && typeParts[0].contains(term, Qt::CaseInsensitive); + const QString type = info->getCardType(); + const qsizetype separator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR); + if (separator >= 0) { + return QStringView(type).left(separator).contains(term, Qt::CaseInsensitive); + } + return type.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptSubType(const CardInfoPtr info) const +bool FilterItem::acceptSubType(const CardInfoPtr &info) const { - const QStringList typeParts = info->getCardType().split(" — "); - return typeParts.size() > 1 && typeParts[1].contains(term, Qt::CaseInsensitive); + const QString type = info->getCardType(); + const qsizetype separator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR); + if (separator < 0) { + return false; + } + + const qsizetype subTypeStart = separator + MAIN_SUB_TYPE_SEPARATOR.size(); + const qsizetype nextSeparator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR, subTypeStart); + const QStringView subType = nextSeparator < 0 ? QStringView(type).mid(subTypeStart) + : QStringView(type).mid(subTypeStart, nextSeparator - subTypeStart); + return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr info) const +bool FilterItem::acceptColor(const CardInfoPtr &info) const { - QString converted_term = term.trimmed(); - - converted_term.replace("green", "g", Qt::CaseInsensitive); - converted_term.replace("grn", "g", Qt::CaseInsensitive); - converted_term.replace("blue", "u", Qt::CaseInsensitive); - converted_term.replace("blu", "u", Qt::CaseInsensitive); - converted_term.replace("black", "b", Qt::CaseInsensitive); - converted_term.replace("blk", "b", Qt::CaseInsensitive); - converted_term.replace("red", "r", Qt::CaseInsensitive); - converted_term.replace("white", "w", Qt::CaseInsensitive); - converted_term.replace("wht", "w", Qt::CaseInsensitive); - converted_term.replace("colorless", "c", Qt::CaseInsensitive); - converted_term.replace("colourless", "c", Qt::CaseInsensitive); - converted_term.replace("none", "c", Qt::CaseInsensitive); - - converted_term.replace(QString(" "), QString(""), Qt::CaseInsensitive); + const QString colors = info->getColors(); // Colorless card filter - if (converted_term.toLower() == "c" && info->getColors().length() < 1) { + if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { return true; } @@ -256,16 +372,16 @@ bool FilterItem::acceptColor(const CardInfoPtr info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (auto &it : converted_term) { - if (info->getColors().contains(it, Qt::CaseInsensitive)) { + for (const QChar &color : convertedColorTerm) { + if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == converted_term.length(); + return match_count == convertedColorTerm.length(); } -bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishText = info->getText(); const QString &localizedText = info->getLocalizedText(searchLanguage.language); @@ -288,51 +404,47 @@ bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &se return false; } -bool FilterItem::acceptSet(const CardInfoPtr info) const +bool FilterItem::acceptSet(const CardInfoPtr &info) const { - bool status = false; for (const auto &printings : info->getSets()) { - for (const auto &set : printings) { - if (set.getSet()->getShortName().compare(term, Qt::CaseInsensitive) == 0 || - set.getSet()->getLongName().compare(term, Qt::CaseInsensitive) == 0) { - status = true; - break; + for (const auto &printing : printings) { + const CardSetPtr set = printing.getSet(); + if (set && (set->getShortName().compare(term, Qt::CaseInsensitive) == 0 || + set->getLongName().compare(term, Qt::CaseInsensitive) == 0)) { + return true; } } } - return status; + return false; } -bool FilterItem::acceptManaCost(const CardInfoPtr info) const +bool FilterItem::acceptManaCost(const CardInfoPtr &info) const { - QString partialCost = term.toUpper(); - - // Sort the mana cost so it will be easy to find - std::sort(partialCost.begin(), partialCost.end()); - // Try to seperate the mana cost in case it's a split card // if it's not a split card the loop will run only once - for (QString fullManaCost : info->getManaCost().split("//")) { + for (const QString &manaCostPart : info->getManaCost().split("//")) { + QString fullManaCost = manaCostPart; std::sort(fullManaCost.begin(), fullManaCost.end()); // If the partial is found in the full, return true - if (fullManaCost.contains(partialCost)) { + if (fullManaCost.contains(sortedManaCostTerm)) { return true; } } return false; } -bool FilterItem::acceptCmc(const CardInfoPtr info) const +bool FilterItem::acceptCmc(const CardInfoPtr &info) const { + const QString cmc = info->getCmc(); bool convertSuccess; - int cmcInt = info->getCmc().toInt(&convertSuccess); + int cmcInt = cmc.toInt(&convertSuccess); // if conversion failed, check for the "//" separator used in split cards if (!convertSuccess) { int cmcSum = 0; - for (const QString &cmc : info->getCmc().split("//")) { - cmcInt = cmc.toInt(); + for (const QString &cmcPart : cmc.split("//")) { + cmcInt = cmcPart.toInt(); cmcSum += cmcInt; if (relationCheck(cmcInt)) { return true; @@ -344,39 +456,36 @@ bool FilterItem::acceptCmc(const CardInfoPtr info) const } } -bool FilterItem::acceptFormat(const CardInfoPtr info) const +bool FilterItem::acceptFormat(const CardInfoPtr &info) const { - return info->getLegalityProp(term.toLower()) == "legal"; + return info->getLegalityProp(lowercaseTerm) == "legal"; } -bool FilterItem::acceptLoyalty(const CardInfoPtr info) const +bool FilterItem::acceptLoyalty(const CardInfoPtr &info) const { - if (info->getLoyalty().isEmpty()) { + const QString loyaltyString = info->getLoyalty(); + if (loyaltyString.isEmpty()) { return false; - } else { - bool success; - // if loyalty can't be converted to "int" it must be "X" - int loyalty = info->getLoyalty().toInt(&success); - if (success) { - return relationCheck(loyalty); - } else { - return term.trimmed().toUpper() == info->getLoyalty(); - } } + + bool success; + // if loyalty can't be converted to "int" it must be "X" + int loyalty = loyaltyString.toInt(&success); + if (success) { + return relationCheck(loyalty); + } + return uppercaseLoyaltyTerm == loyaltyString; } -bool FilterItem::acceptPowerToughness(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItem::acceptPowerToughness(const CardInfoPtr &info, CardFilter::Attr attr) const { - int slash = info->getPowTough().indexOf("/"); + const QString powTough = info->getPowTough(); + const qsizetype slash = powTough.indexOf("/"); if (slash == -1) { return false; } - QString valueString; - if (attr == CardFilter::AttrPow) { - valueString = info->getPowTough().mid(0, slash); - } else { - valueString = info->getPowTough().mid(slash + 1); - } + + QString valueString = attr == CardFilter::AttrPow ? powTough.mid(0, slash) : powTough.mid(slash + 1); if (term == valueString) { return true; } @@ -386,49 +495,11 @@ bool FilterItem::acceptPowerToughness(const CardInfoPtr info, CardFilter::Attr a return conversion ? relationCheck(value) : false; } -bool FilterItem::acceptRarity(const CardInfoPtr info) const +bool FilterItem::acceptRarity(const CardInfoPtr &info) const { - QString converted_term = term.trimmed(); - - /* - * The purpose of this loop is to only apply one of the replacement - * policies and then escape. If we attempt to layer them on top of - * each other, we will get awkward results (i.e. comythic rare mythic rareon) - * Conditional statement will exit once a case is successful in - * replacement OR we go through all possible cases. - * Will also need to replace just "mythic" - */ - for (int i = 0; converted_term.length() <= 3 && i <= 6; i++) { - switch (i) { - case 0: - converted_term.replace("mr", "mythic", Qt::CaseInsensitive); - break; - case 1: - converted_term.replace("m r", "mythic", Qt::CaseInsensitive); - break; - case 2: - converted_term.replace("m", "mythic", Qt::CaseInsensitive); - break; - case 3: - converted_term.replace("c", "common", Qt::CaseInsensitive); - break; - case 4: - converted_term.replace("u", "uncommon", Qt::CaseInsensitive); - break; - case 5: - converted_term.replace("r", "rare", Qt::CaseInsensitive); - break; - case 6: - converted_term.replace("s", "special", Qt::CaseInsensitive); - break; - default: - break; - } - } - for (const auto &printings : info->getSets()) { for (const auto &printing : printings) { - if (printing.getProperty("rarity").compare(converted_term, Qt::CaseInsensitive) == 0) { + if (printing.getProperty("rarity").compare(convertedRarityTerm, Qt::CaseInsensitive) == 0) { return true; } } @@ -438,41 +509,25 @@ bool FilterItem::acceptRarity(const CardInfoPtr info) const bool FilterItem::relationCheck(int cardInfo) const { - bool result, conversion; - - // if int conversion fails, there's probably an operator at the start - result = (cardInfo == term.toInt(&conversion)); - if (!conversion) { - // leading whitespaces could cause indexing to fail - QString trimmedTerm = term.trimmed(); - // check whether it's a 2 char operator (<=, >=, or ==) - if (trimmedTerm[1] == '=') { - int termInt = trimmedTerm.mid(2).toInt(); - if (trimmedTerm.startsWith('<')) { - result = (cardInfo <= termInt); - } else if (trimmedTerm.startsWith('>')) { - result = (cardInfo >= termInt); - } else { - result = (cardInfo == termInt); - } - } else { - int termInt = trimmedTerm.mid(1).toInt(); - if (trimmedTerm.startsWith('<')) { - result = (cardInfo < termInt); - } else if (trimmedTerm.startsWith('>')) { - result = (cardInfo > termInt); - } else if (trimmedTerm.startsWith("=")) { - result = (cardInfo == termInt); - } else { - // the int conversion hasn't failed due to an operator at the start - result = false; - } - } + switch (numericOperator) { + case NumericOperator::Equal: + return cardInfo == numericOperand; + case NumericOperator::Less: + return cardInfo < numericOperand; + case NumericOperator::LessEqual: + return cardInfo <= numericOperand; + case NumericOperator::Greater: + return cardInfo > numericOperand; + case NumericOperator::GreaterEqual: + return cardInfo >= numericOperand; + case NumericOperator::Invalid: + return false; } - return result; + + return false; } -bool FilterItem::acceptCardAttr(const CardInfoPtr info, +bool FilterItem::acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -556,7 +611,7 @@ FilterTreeNode *FilterTree::termNode(const CardFilter *f) return termNode(f->attr(), f->type(), f->term()); } -bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const +bool FilterTree::testAttr(const CardInfoPtr &info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const { const FilterItemList *fil; bool status = true; @@ -589,7 +644,7 @@ bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const Card return status; } -bool FilterTree::acceptsCard(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterTree::acceptsCard(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) { if ((*i)->isEnabled() && !testAttr(info, *i, searchLanguage)) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 0defd466b..85a57304d 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -174,13 +174,13 @@ public: } [[nodiscard]] bool - testTypeAnd(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeAnd(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeAndNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeOr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeOr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeOrNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; }; class FilterItem : public FilterTreeNode @@ -188,12 +188,33 @@ class FilterItem : public FilterTreeNode private: FilterItemList *const p; + /** + * Values derived from the filter term that never change while this node + * exists. Computing them in the constructor keeps them out of the per-card + * evaluation path. + */ + QString convertedColorTerm; + QString convertedRarityTerm; + QString sortedManaCostTerm; + QString uppercaseLoyaltyTerm; + QString lowercaseTerm; + + enum class NumericOperator + { + Invalid, + Equal, + Less, + LessEqual, + Greater, + GreaterEqual, + }; + NumericOperator numericOperator = NumericOperator::Invalid; + int numericOperand = 0; + public: const QString term; - FilterItem(QString trm, FilterItemList *parent) : p(parent), term(std::move(trm)) - { - } + FilterItem(QString trm, FilterItemList *parent); virtual ~FilterItem() = default; [[nodiscard]] CardFilter::Attr attr() const @@ -217,22 +238,22 @@ public: return true; } - [[nodiscard]] bool acceptName(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptNameExact(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptType(CardInfoPtr info) const; - [[nodiscard]] bool acceptMainType(CardInfoPtr info) const; - [[nodiscard]] bool acceptSubType(CardInfoPtr info) const; - [[nodiscard]] bool acceptColor(CardInfoPtr info) const; - [[nodiscard]] bool acceptText(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptSet(CardInfoPtr info) const; - [[nodiscard]] bool acceptManaCost(CardInfoPtr info) const; - [[nodiscard]] bool acceptCmc(CardInfoPtr info) const; - [[nodiscard]] bool acceptPowerToughness(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool acceptLoyalty(CardInfoPtr info) const; - [[nodiscard]] bool acceptRarity(CardInfoPtr info) const; + [[nodiscard]] bool acceptName(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptNameExact(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptType(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptCmc(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptPowerToughness(const CardInfoPtr &info, CardFilter::Attr attr) const; + [[nodiscard]] bool acceptLoyalty(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptRarity(const CardInfoPtr &info) const; [[nodiscard]] bool - acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptFormat(CardInfoPtr info) const; + acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptFormat(const CardInfoPtr &info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; @@ -251,7 +272,7 @@ private: LogicMap *attrLogicMap(CardFilter::Attr attr); FilterItemList *attrTypeList(CardFilter::Attr attr, CardFilter::Type type); - bool testAttr(CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; + bool testAttr(const CardInfoPtr &info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; void nodeChanged() const override { @@ -290,7 +311,7 @@ public: return 0; } - [[nodiscard]] bool acceptsCard(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptsCard(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; void removeFiltersByAttr(CardFilter::Attr filterType); void removeFilter(const CardFilter *toRemove); void clear(); From c25a2c5c3ba7daa1f05688f652a96a746076b8df Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:58:58 +0200 Subject: [PATCH 096/247] [CardDB] Avoid copying printing maps and property strings (#7605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Avoid copying printing maps and property strings Reading a card's sets was a measured hotspot: `SetToPrintingsMap setMap = card->getSets();` copies the whole QMap, and then iterating it with a non-const iterator detaches and deep-copies every PrintingInfo, including its property hash, on first use. A micro-benchmark of that exact pattern (copy plus non-const iteration) came out about 19x slower than a const reference over the same data. The querier, the cache writer, the XML writer, the picture loader and the printing/set selection widgets now take `getSets()` by const reference and only use const iterators. The tests that iterate generated data are covered by the existing card database and picture loader suites. String accessors were also returning by value where the value lives in a hash that has already been materialized: - CardInfo::getProperty() and the legacy getCardType()/getColors()/ getManaCost()/getCmc()/getPowTough()/getLoyalty() accessors, - PrintingInfo::getProperty(), getUuid(), getFlavorName(), getArtist(), and getSet(), - ExactCard::getPrinting() and getCardPtr(), - CardSet::getShortName(), getLongName() and getSetType(). getPrinting() in particular copied a PrintingInfo on every call, and a copy of an unmaterialized PrintingInfo has to take the properties mutex. Callers that need an owned copy still get one; the rest just read. Other allocation removals in the same path: - LazyPropertiesHash::ensureMaterialized() checks an atomic flag before taking the mutex, so property reads after the first one do not lock. The flag is release-stored after materialization and acquire-loaded by readers/copies, preserving the existing locking for the first read. - CardSet::getCorrectedShortName() no longer rebuilds its QSet of reserved Windows file names on every call. - CardSet::getIsKnownIgnored() no longer formats the release date into a throwaway string just to check its length. - The querier builds its card-type/format count caches without `cards.values()` or a properties key list, reserves its result lists, and picks a random card without materializing `cards.keys()`. - The querier's pinned-printing path reuses the CardInfoPtr it already has instead of looking the card up again by name. - CardDatabaseCache/XML serialization copy the alt-name QSet by reference. - The picture loader compiles its !prop!/!set! regular expressions once instead of per URL template, and walks the transform map with an iterator instead of keys() plus operator[]. Full build and ctest (628 tests) pass. * [CardDB] Address review comments PrintingInfo is a value type: it is stored in QLists and returned by value from the querier, so a const-ref getter can hand out a reference into a temporary. Return the QStrings and the CardSetPtr by value again. The constFind lookup stays, so a property read still never materializes the lazy property hash - only the last shared-data refcount is paid. * [CardDB] Undo the const-ref QString return changes QString is implicitly shared, so returning by value costs only a refcount and avoids handing out references into a lazily materialized hash. The constFind lookups stay, so a property read still never materializes the hash. * [CardDB] Use QMap::asKeyValueRange() in transformUrl The range is a view over the map's own iterators and yields references to the stored key and value, so it keeps the no-allocation property of the manual const iteration. --------- Co-authored-by: Lukas Brübach --- .../card_picture_to_load.cpp | 24 ++++--- .../dialogs/dlg_select_set_for_cards.cpp | 10 +-- .../printing_selector/printing_selector.cpp | 2 +- .../printing_selector_card_sorting_widget.cpp | 2 +- .../visual_database_display_widget.cpp | 7 +- .../libcockatrice/card/card_info.cpp | 2 +- .../libcockatrice/card/card_info.h | 6 +- .../card/database/card_database_cache.cpp | 4 +- .../card/database/card_database_querier.cpp | 71 ++++++++++--------- .../card/database/parser/cockatrice_xml_3.cpp | 2 +- .../card/lazy_properties_hash.cpp | 22 +++--- .../libcockatrice/card/lazy_properties_hash.h | 9 +-- .../libcockatrice/card/printing/exact_card.h | 4 +- .../card/printing/printing_info.cpp | 4 +- .../card/printing/printing_info.h | 4 +- .../libcockatrice/card/set/card_set.cpp | 12 ++-- .../libcockatrice/card/set/card_set.h | 2 +- 17 files changed, 108 insertions(+), 79 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index e35c417c8..8e8feb5b5 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -21,6 +21,7 @@ #include #include #include +#include CardPictureToLoad::CardPictureToLoad(const ExactCard &_card) : card(_card), urlTemplates(SettingsCache::instance().downloads().getAllURLs()) @@ -74,19 +75,20 @@ QList CardPictureToLoad::extractSetsSorted(const ExactCard &card) //! \todo Check if going through all sets is still necessary after the ExactCard refactor. static PrintingInfo findPrintingForSet(const ExactCard &card, const QString &setName) { - SetToPrintingsMap setsToPrintings = card.getInfo().getSets(); + const auto &setsToPrintings = card.getInfo().getSets(); - if (!setsToPrintings.contains(setName)) { + const auto setPrintings = setsToPrintings.constFind(setName); + if (setPrintings == setsToPrintings.constEnd() || setPrintings.value().isEmpty()) { return PrintingInfo(); } - for (const auto &printing : setsToPrintings[setName]) { + for (const auto &printing : setPrintings.value()) { if (printing.getUuid() == card.getPrinting().getUuid()) { return printing; } } - return setsToPrintings[setName][0]; + return setPrintings.value().first(); } void CardPictureToLoad::populateSetUrls() @@ -183,10 +185,14 @@ static int parse(const QString &urlTemplate, { static const QRegularExpression rxFillWith("^(.+)_fill_with_(.+)$"); static const QRegularExpression rxSubStr("^(.+)_substr_(\\d+)_(\\d+)$"); + static const QRegularExpression rxCardProp("!prop:([^!]+)!"); + static const QRegularExpression rxCardSet("!set:([^!]+)!"); - const QRegularExpression rxCardProp("!" + propType + ":([^!]+)!"); + // The property type only takes two values, so keep the expression that was + // compiled for it instead of rebuilding one per URL template. + const QRegularExpression &rxCardPropSelector = propType == "prop" ? rxCardProp : rxCardSet; - auto matches = rxCardProp.globalMatch(urlTemplate); + auto matches = rxCardPropSelector.globalMatch(urlTemplate); while (matches.hasNext()) { auto match = matches.next(); QString templatePropertyName = match.captured(1); @@ -303,10 +309,10 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } QString transformedUrl = urlTemplate; - for (const QString &prop : transformMap.keys()) { + for (const auto &[prop, value] : std::as_const(transformMap).asKeyValueRange()) { if (transformedUrl.contains(prop)) { - if (!transformMap[prop].isEmpty()) { - transformedUrl.replace(prop, QUrl::toPercentEncoding(transformMap[prop])); + if (!value.isEmpty()) { + transformedUrl.replace(prop, QUrl::toPercentEncoding(value)); } else { /* This means the template is requesting information that is not * populated in this card, so it should return an empty string, diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 55c6569f7..57ea09628 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -274,9 +274,9 @@ QMap DlgSelectSetForCards::getSetsForCards() continue; } - SetToPrintingsMap setMap = infoPtr->getSets(); - for (auto &setName : setMap.keys()) { - setCounts[setName]++; + const auto &setMap = infoPtr->getSets(); + for (auto it = setMap.constBegin(); it != setMap.constEnd(); ++it) { + setCounts[it.key()]++; } } @@ -393,8 +393,8 @@ QMap DlgSelectSetForCards::getCardsForSets() continue; } - SetToPrintingsMap setMap = infoPtr->getSets(); - for (auto it = setMap.begin(); it != setMap.end(); ++it) { + const auto &setMap = infoPtr->getSets(); + for (auto it = setMap.constBegin(); it != setMap.constEnd(); ++it) { setCards[it.key()].append(cardName); } } diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 27ae61b94..983d6ba32 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -229,7 +229,7 @@ void PrintingSelector::getAllSetsForCurrentCard() return; } - SetToPrintingsMap setMap = selectedCard->getSets(); + const auto &setMap = selectedCard->getSets(); const QList sortedPrintings = sortToolBar->sortSets(setMap); const QList filteredPrintings = sortToolBar->filterSets(sortedPrintings, searchBar->getSearchText().trimmed().toLower()); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp index f47ba0746..1cd372259 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp @@ -208,7 +208,7 @@ QList PrintingSelectorCardSortingWidget::prependPrintingsInDeck(co return {}; } - SetToPrintingsMap setMap = selectedCard->getSets(); + const auto &setMap = selectedCard->getSets(); QList> countList; // Collect sets with their counts diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 599ac97b4..1a54ff0f5 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -432,10 +432,11 @@ void VisualDatabaseDisplayWidget::loadPage(int start, int end) if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) { if (!setFilters.empty()) { - SetToPrintingsMap setMap = info->getSets(); + const auto &setMap = info->getSets(); for (const CardFilter *setFilter : setFilters) { - if (setMap.contains(setFilter->term())) { - for (PrintingInfo printing : setMap[setFilter->term()]) { + const auto it = setMap.constFind(setFilter->term()); + if (it != setMap.constEnd()) { + for (const PrintingInfo &printing : it.value()) { addCardToDisplay(ExactCard(info, printing)); } } diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index bd33f18e7..ae2382166 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -245,7 +245,7 @@ QString CardInfo::simplifyName(const QString &name) QChar CardInfo::getColorChar() const { - QString colors = getColors(); + const QString colors = getColors(); switch (colors.size()) { case 0: return QChar(); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 356da0546..c0dccb885 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -280,7 +280,7 @@ public: { return simpleName; } - const QSet &getAltNames() + const QSet &getAltNames() const { return altNames; } @@ -395,7 +395,9 @@ public: [[nodiscard]] QString getProperty(const QString &propertyName) const { - return getPropertiesHash().value(propertyName); + const auto &propertiesHash = getPropertiesHash(); + const auto it = propertiesHash.constFind(propertyName); + return it != propertiesHash.constEnd() ? it.value() : QString(); } void setProperty(const QString &_name, const QString &_value); [[nodiscard]] bool hasProperty(const QString &propertyName) const diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 4ab4d1fca..42658dd6b 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -210,7 +210,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) // per-printing alt-name scan entirely. writeString(out, card->getSimpleName()); { - const QSet altNames = card->getAltNames(); + const QSet &altNames = card->getAltNames(); out << static_cast(altNames.size()); for (const QString &alt : altNames) { writeString(out, alt); @@ -218,7 +218,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) } // setsToPrintings - const SetToPrintingsMap sets = card->getSets(); + const auto &sets = card->getSets(); out << static_cast(sets.size()); for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { writeString(out, it.key()); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 7c41daa3b..8df0d2e20 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include CardDatabaseQuerier::CardDatabaseQuerier(QObject *_parent, @@ -52,6 +53,7 @@ CardInfoPtr CardDatabaseQuerier::getCardInfo(const QString &cardName) const QList CardDatabaseQuerier::getCardInfos(const QStringList &cardNames) const { QList cardInfos; + cardInfos.reserve(cardNames.size()); for (const QString &cardName : cardNames) { CardInfoPtr ptr = db->cards.value(cardName); if (ptr) { @@ -90,6 +92,7 @@ CardInfoPtr CardDatabaseQuerier::lookupCardByName(const QString &name) const QList CardDatabaseQuerier::getCards(const QList &cardRefs) const { QList cards; + cards.reserve(cardRefs.size()); for (const auto &cardRef : cardRefs) { ExactCard card = getCard(cardRef); if (card) { @@ -149,10 +152,10 @@ ExactCard CardDatabaseQuerier::getRandomCard() const return {}; } - const auto keys = db->cards.keys(); - int randomIndex = QRandomGenerator::global()->bounded(keys.size()); - const QString &randomKey = keys.at(randomIndex); - CardInfoPtr randomCard = getCardInfo(randomKey); + const auto randomIndex = QRandomGenerator::global()->bounded(db->cards.size()); + auto it = db->cards.constBegin(); + std::advance(it, randomIndex); + CardInfoPtr randomCard = it.value(); return ExactCard{randomCard, getPreferredPrinting(randomCard)}; } @@ -215,20 +218,23 @@ PrintingInfo CardDatabaseQuerier::getSpecificPrinting(const QString &cardName, return PrintingInfo(nullptr); } - SetToPrintingsMap setMap = cardInfo->getSets(); + const auto &setMap = cardInfo->getSets(); if (setMap.empty()) { return PrintingInfo(nullptr); } for (const auto &printings : setMap) { - for (auto &cardInfoForSet : printings) { + for (const auto &cardInfoForSet : printings) { + const CardSetPtr &set = cardInfoForSet.getSet(); + if (!set) { + continue; + } if (!collectorNumber.isEmpty()) { - if (cardInfoForSet.getSet()->getShortName() == setShortName && - cardInfoForSet.getProperty("num") == collectorNumber) { + if (set->getShortName() == setShortName && cardInfoForSet.getProperty("num") == collectorNumber) { return cardInfoForSet; } } else { - if (cardInfoForSet.getSet()->getShortName() == setShortName) { + if (set->getShortName() == setShortName) { return cardInfoForSet; } } @@ -284,10 +290,10 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn const auto &pinnedPrintingProviderId = prefs->getCardPreferenceOverride(cardInfo->getName()); if (!pinnedPrintingProviderId.isEmpty()) { - return getSpecificPrinting({cardInfo->getName(), pinnedPrintingProviderId}); + return findPrintingWithId(cardInfo, pinnedPrintingProviderId); } - SetToPrintingsMap setMap = cardInfo->getSets(); + const auto &setMap = cardInfo->getSets(); if (setMap.empty()) { return PrintingInfo(nullptr); } @@ -297,8 +303,8 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn SetPriorityComparator comparator; for (const auto &printings : setMap) { - for (auto &printing : printings) { - CardSetPtr currentSet = printing.getSet(); + for (const auto &printing : printings) { + const CardSetPtr ¤tSet = printing.getSet(); if (!preferredSet || comparator(currentSet, preferredSet)) { preferredSet = currentSet; preferredPrinting = printing; @@ -315,17 +321,18 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn QString CardDatabaseQuerier::getPreferredPrintingProviderId(const QString &cardName) const { - PrintingInfo preferredPrinting = getPreferredPrinting(cardName); - QString uuid = preferredPrinting.getUuid(); + CardInfoPtr cardInfo = getCardInfo(cardName); + if (!cardInfo) { + return cardName; + } + + PrintingInfo preferredPrinting = getPreferredPrinting(cardInfo); + const QString &uuid = preferredPrinting.getUuid(); if (!uuid.isEmpty()) { return uuid; } - CardInfoPtr defaultCardInfo = getCardInfo(cardName); - if (defaultCardInfo.isNull()) { - return cardName; - } - return defaultCardInfo->getName(); + return cardInfo->getName(); } QStringList CardDatabaseQuerier::getAllMainCardTypes() const @@ -339,8 +346,8 @@ QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const // produces an empty map, so the cache is only ever empty when it needs a // (trivially cheap) rebuild. if (mainCardTypeCountsCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QString type = card->getMainCardType(); + for (const auto &card : db->cards) { + const QString type = card->getMainCardType(); mainCardTypeCountsCache[type]++; } } @@ -351,13 +358,13 @@ QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const QMap CardDatabaseQuerier::getAllSubCardTypesWithCount() const { if (subCardTypeCountsCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QString type = card->getCardType(); + for (const auto &card : db->cards) { + const QString type = card->getCardType(); - QStringList parts = type.split(" — "); + const QStringList parts = type.split(" — "); if (parts.size() > 1) { // Ensure there are subtypes - QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); + const QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); for (const QString &subtype : subtypes) { subCardTypeCountsCache[subtype]++; @@ -377,13 +384,13 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const QMap CardDatabaseQuerier::getAllFormatsWithCount() const { if (formatsCountCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QStringList allProps = card->getProperties(); + static const QString formatPrefix = QStringLiteral("format-"); - for (const QString &prop : allProps) { - if (prop.startsWith("format-")) { - QString formatName = prop.mid(QStringLiteral("format-").size()); - formatsCountCache[formatName]++; + for (const auto &card : db->cards) { + const auto &properties = card->getPropertiesHash(); + for (auto it = properties.constBegin(); it != properties.constEnd(); ++it) { + if (it.key().startsWith(formatPrefix)) { + formatsCountCache[it.key().mid(formatPrefix.size())]++; } } } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index b17eb8b5e..33a0e0849 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -401,7 +401,7 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } // sets - const SetToPrintingsMap setMap = info->getSets(); + const auto &setMap = info->getSets(); for (const auto &printings : setMap) { for (const PrintingInfo &set : printings) { xml.writeStartElement("set"); diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp index a12de469b..90628224a 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -20,15 +20,15 @@ LazyPropertiesHash::LazyPropertiesHash(const QHash &properties LazyPropertiesHash::LazyPropertiesHash(const LazyPropertiesHash &other) { // since we do not allow dematerialization, we only need to lock if not materialized yet - if (other.isMaterialized) { + if (other.isMaterialized.load(std::memory_order_acquire)) { blob = other.blob; properties = other.properties; - isMaterialized = true; + isMaterialized.store(true, std::memory_order_relaxed); } else { QMutexLocker lock(&other.propertiesMutex); blob = other.blob; properties = other.properties; - isMaterialized = false; + isMaterialized.store(other.isMaterialized.load(std::memory_order_relaxed), std::memory_order_relaxed); } } @@ -39,15 +39,15 @@ LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &othe } // since we do not allow dematerialization, we only need to lock if not materialized yet - if (other.isMaterialized) { + if (other.isMaterialized.load(std::memory_order_acquire)) { blob = other.blob; properties = other.properties; - isMaterialized = true; + isMaterialized.store(true, std::memory_order_relaxed); } else { QMutexLocker lock(&other.propertiesMutex); blob = other.blob; properties = other.properties; - isMaterialized = false; + isMaterialized.store(false, std::memory_order_relaxed); } return *this; @@ -55,9 +55,15 @@ LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &othe void LazyPropertiesHash::ensureMaterialized() const { + // Materialization happens once and is never undone, so the common case can + // skip the mutex entirely once it has happened. + if (isMaterialized.load(std::memory_order_acquire)) { + return; + } + QMutexLocker lock(&propertiesMutex); - if (isMaterialized) { + if (isMaterialized.load(std::memory_order_relaxed)) { return; } @@ -69,7 +75,7 @@ void LazyPropertiesHash::ensureMaterialized() const blob.clear(); - isMaterialized = true; + isMaterialized.store(true, std::memory_order_release); } QString LazyPropertiesHash::value(const QString &key) const diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h index 04f5d0251..9e34050e5 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -5,6 +5,7 @@ #include #include #include +#include /** * @brief A property map that can lazily deserialize blobs to avoid loading overhead. @@ -18,10 +19,10 @@ class LazyPropertiesHash { - mutable QByteArray blob; ///< Serialized properties (load form). - mutable QHash properties; ///< Materialized properties (query form). - mutable QMutex propertiesMutex; ///< Guards lazy materialization. - mutable bool isMaterialized = false; ///< Whether propertiesCache is valid. + mutable QByteArray blob; ///< Serialized properties (load form). + mutable QHash properties; ///< Materialized properties (query form). + mutable QMutex propertiesMutex; ///< Guards lazy materialization. + mutable std::atomic isMaterialized{false}; ///< Whether properties is valid. /** * @brief Materializes properties from blob if not already done. Clears blob afterward. diff --git a/libcockatrice_card/libcockatrice/card/printing/exact_card.h b/libcockatrice_card/libcockatrice/card/printing/exact_card.h index ea49072fb..83ba97bd0 100644 --- a/libcockatrice_card/libcockatrice/card/printing/exact_card.h +++ b/libcockatrice_card/libcockatrice/card/printing/exact_card.h @@ -47,7 +47,7 @@ public: * * May be null if the ExactCard is empty. */ - [[nodiscard]] CardInfoPtr getCardPtr() const + [[nodiscard]] const CardInfoPtr &getCardPtr() const { return card; } @@ -57,7 +57,7 @@ public: * * May be empty if no specific printing was assigned. */ - [[nodiscard]] PrintingInfo getPrinting() const + [[nodiscard]] const PrintingInfo &getPrinting() const { return printing; } diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 654efac5e..9440eb4b0 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -17,10 +17,10 @@ void PrintingInfo::setProperty(const QString &_name, const QString &_value) */ QString PrintingInfo::getUuid() const { - return getPropertiesHash().value("uuid"); + return getProperty("uuid"); } QString PrintingInfo::getFlavorName() const { - return getPropertiesHash().value("flavorName"); + return getProperty("flavorName"); } diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index 038960e86..75f634c3c 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -107,7 +107,9 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName); + const auto &propertiesHash = getPropertiesHash(); + const auto it = propertiesHash.constFind(propertyName); + return it != propertiesHash.constEnd() ? it.value() : QString(); } /** diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index e873eeeab..e2b41a811 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -34,10 +34,14 @@ CardSetPtr CardSet::newInstance(ICardSetPriorityController *_priorityController, QString CardSet::getCorrectedShortName() const { // For Windows machines. - QSet invalidFileNames; - invalidFileNames << "CON" << "PRN" << "AUX" << "NUL" << "COM1" << "COM2" << "COM3" << "COM4" << "COM5" << "COM6" - << "COM7" << "COM8" << "COM9" << "LPT1" << "LPT2" << "LPT3" << "LPT4" << "LPT5" << "LPT6" << "LPT7" - << "LPT8" << "LPT9"; + static const QSet invalidFileNames = { + QStringLiteral("CON"), QStringLiteral("PRN"), QStringLiteral("AUX"), QStringLiteral("NUL"), + QStringLiteral("COM1"), QStringLiteral("COM2"), QStringLiteral("COM3"), QStringLiteral("COM4"), + QStringLiteral("COM5"), QStringLiteral("COM6"), QStringLiteral("COM7"), QStringLiteral("COM8"), + QStringLiteral("COM9"), QStringLiteral("LPT1"), QStringLiteral("LPT2"), QStringLiteral("LPT3"), + QStringLiteral("LPT4"), QStringLiteral("LPT5"), QStringLiteral("LPT6"), QStringLiteral("LPT7"), + QStringLiteral("LPT8"), QStringLiteral("LPT9"), + }; return invalidFileNames.contains(shortName) ? shortName + "_" : shortName; } diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.h b/libcockatrice_card/libcockatrice/card/set/card_set.h index b9ac74e2d..b995ac88e 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set.h @@ -239,7 +239,7 @@ public: */ [[nodiscard]] bool getIsKnownIgnored() const { - return longName.length() + setType.length() + releaseDate.toString().length() == 0; + return longName.isEmpty() && setType.isEmpty() && !releaseDate.isValid(); } }; From ad4320cd7b62b2cce20f2150488b6fa0c50a52f9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:17:31 +0200 Subject: [PATCH 097/247] [CardDB] Stream card properties from blobs (#7608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Stream card properties from blobs Parsed and cached cards used to keep a materialized QHash for their properties: LazyPropertiesHash only deferred building that hash until the first query, and the cache writer re-serialized it on save. - LazyPropertiesHash answers value(), contains(), size(), isEmpty() and keys() straight out of the serialized blob, so a property query no longer builds a hash. - serialize() is public, and the cache writer passes the stored blob through instead of serializing a hash it just rebuilt. - CardInfo and PrintingInfo expose getPropertiesBlob() and getPropertyCount(); PrintingInfo::operator== compares key by key without materializing either side. - The XML parsers build cards via newInstanceFromPropertiesBlob(), which derives the simple name and the alternate names that the parser used to keep next to the hash. The reload cycle fix stays out of this change; it depends on changing the CardInfo self pointer and is reviewed separately. * [CardDB] Address review comments on streaming properties * [CardDB] Address final review nits on property streaming Move BlobReader and the blob accessors into LazyPropertiesHash as private members so the parsing helper is declared with the class instead of hiding in the translation unit. Add LazyPropertiesHash::serializedFrom() and use it where a PrintingInfo is built from a fresh property hash, replacing the nested LazyPropertiesHash(LazyPropertiesHash::serialize(props)) construction. The pre-materialized QHash constructor stays: CardInfo's non-blob factory still uses it for the oracle import path, and the tests cover it directly. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/card/card_info.cpp | 12 +- .../libcockatrice/card/card_info.h | 60 +++-- .../card/database/card_database_cache.cpp | 13 +- .../card/database/parser/cockatrice_xml_3.cpp | 7 +- .../card/database/parser/cockatrice_xml_4.cpp | 8 +- .../card/lazy_properties_hash.cpp | 213 +++++++++++++++++- .../libcockatrice/card/lazy_properties_hash.h | 128 ++++++++++- .../card/printing/printing_info.cpp | 17 ++ .../card/printing/printing_info.h | 26 ++- oracle/src/oracleimporter.cpp | 2 +- tests/carddatabase/carddatabase_test.cpp | 46 ++++ tests/carddatabase/load_benchmark.cpp | 2 +- 12 files changed, 475 insertions(+), 59 deletions(-) diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index ae2382166..c90027442 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -67,10 +67,16 @@ CardInfo::CardInfo(const QString &_name, reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) { - // propertiesBlob is materialized lazily on first query; simpleName and - // altNames are supplied by the caller (binary cache). Only the set-name - // display list still depends on the current enabled-set state. + // propertiesBlob is materialized lazily on first query. The binary cache supplies simpleName + // and altNames to skip the Unicode normalization and the printing scan; the XML parsers leave + // them empty and get the same derived state the property-hash constructor computes. + if (simpleName.isEmpty()) { + simpleName = simplifyName(name); + } refreshCachedSetNames(); + if (altNames.isEmpty()) { + refreshCachedAltNames(); + } } CardInfoPtr CardInfo::newInstance(const QString &_name) diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index c0dccb885..77ddce5ce 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -121,14 +121,12 @@ public: QMap _localizedTexts = {}); /** - * @brief Constructs a CardInfo from a cache snapshot with precomputed derived - * state. + * @brief Constructs a CardInfo from a serialized properties blob. * - * Used by the binary cache reader to skip recomputing @p _simpleName and - * @p _altNames (which otherwise require a Unicode normalization and a full - * printing scan). Properties are supplied as a pre-serialized @p _propertiesBlob - * so the QHash is not built at load time (it is materialized on first - * query). + * Properties stay in @p _propertiesBlob so the QHash is not + * built at load time (it is materialized on first query). @p _simpleName and + * @p _altNames are filled in when empty; the binary cache supplies them to + * skip the Unicode normalization and the printing scan. * * @param _name The card name. * @param _text Rules text or description of the card. @@ -138,8 +136,8 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic. - * @param _simpleName Precomputed simplified name. - * @param _altNames Precomputed alternate names. + * @param _simpleName Precomputed simplified name; derived from @p _name when empty. + * @param _altNames Precomputed alternate names; derived from the printings when empty. * @param _localizedNames Localized card names, keyed by language code. * @param _localizedTexts Localized rules text, keyed by language code. */ @@ -210,20 +208,26 @@ public: QMap _localizedTexts = {}); /** - * @brief Creates a new instance from a cache snapshot with precomputed - * derived state. + * @brief Creates a new instance from a serialized properties blob. + * + * The properties stay in blob form, so the QHash is not + * built at load time (it is materialized on first query). The derived state + * is required so it is never recomputed here, and the overload cannot be a + * candidate for a shorter call that passes a property hash instead of a blob. * * @param _name Name of the card. * @param _text Rules text or description. * @param _isToken Token flag. - * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _propertiesBlob Serialized properties blob. * @param _relatedCards Forward relationships. * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic. - * @param _simpleName Precomputed simplified name. - * @param _altNames Precomputed alternate names. - * @param _appendToSets When true (default), the card is appended to each of + * @param _simpleName Precomputed simplified name; pass {} to derive it from + * @p _name. + * @param _altNames Precomputed alternate names; pass {} to derive them from + * the printings. + * @param _appendToSets When true, the card is appended to each of * its CardSets. Pass false when building cards in parallel so the * (non-thread-safe) set membership is populated in a later * single-threaded pass. @@ -241,7 +245,7 @@ public: UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets = true, + bool _appendToSets, QMap _localizedNames = {}, QMap _localizedTexts = {}); @@ -389,20 +393,34 @@ public: } [[nodiscard]] QStringList getProperties() const { - return getPropertiesHash().keys(); + return properties.keys(); } + + /** + * @brief Returns the properties in their serialized blob form. + * + * Cheap while the properties were never materialized (the cache and the XML parsers pass the blob through). + */ + [[nodiscard]] QByteArray getPropertiesBlob() const + { + return properties.serialized(); + } + + /** + * @brief Returns the materialized property hash, forcing deserialization of the blob. + * + * Prefer getProperty(), hasProperty() or getPropertiesBlob(), which avoid building the hash. + */ [[nodiscard]] const QHash &getPropertiesHash() const; [[nodiscard]] QString getProperty(const QString &propertyName) const { - const auto &propertiesHash = getPropertiesHash(); - const auto it = propertiesHash.constFind(propertyName); - return it != propertiesHash.constEnd() ? it.value() : QString(); + return properties.value(propertyName); } void setProperty(const QString &_name, const QString &_value); [[nodiscard]] bool hasProperty(const QString &propertyName) const { - return getPropertiesHash().contains(propertyName); + return properties.contains(propertyName); } [[nodiscard]] const SetToPrintingsMap &getSets() const { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 42658dd6b..611b24ad8 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -53,13 +53,10 @@ QString readString(QDataStream &in) // Stores a QHash as a single pre-serialized blob. The reader keeps the // blob as-is and materializes the QHash lazily on first query, which is // what removes the allocation storm from database load (see card_info.cpp / -// printing_info.cpp). -void writeHashBlob(QDataStream &out, const QHash &h) +// printing_info.cpp). Writing the already-serialized blob also keeps cache writes from +// materializing every card's property hash. +void writeBlob(QDataStream &out, const QByteArray &blob) { - QByteArray blob; - QDataStream blobOut(&blob, QIODevice::WriteOnly); - blobOut.setVersion(QDataStream::Qt_6_4); - blobOut << h; out << static_cast(blob.size()); out.writeRawData(blob.constData(), blob.size()); } @@ -156,7 +153,7 @@ CardRelation *readRelation(QDataStream &in) void writePrinting(QDataStream &out, const PrintingInfo &p) { writeString(out, p.getSet() ? p.getSet()->getShortName() : QString()); - writeHashBlob(out, p.getPropertiesHash()); + writeBlob(out, p.getPropertiesBlob()); } PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) @@ -198,7 +195,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) writeString(out, card->getName()); writeString(out, card->getText()); out << card->getIsToken(); - writeHashBlob(out, card->getPropertiesHash()); + writeBlob(out, card->getPropertiesBlob()); CardInfo::UiAttributes ui = card->getUiAttributes(); out << ui.cipt; diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index 33a0e0849..d40c1c063 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -268,7 +268,7 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) if (attrs.hasAttribute("rarity")) { printingProps.insert("rarity", attrs.value("rarity").toString()); } - PrintingInfo setInfo(set, LazyPropertiesHash(printingProps)); + PrintingInfo setInfo(set, LazyPropertiesHash::serializedFrom(printingProps)); _sets[setName].append(setInfo); } // related cards @@ -325,8 +325,9 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, LazyPropertiesHash::serialize(properties), relatedCards, + reverseRelatedCards, _sets, attributes, {}, {}, true); if (targetData) { if (auto existing = targetData->cards.value(name)) { for (const auto &printings : newCard->getSets()) { diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 4d975e533..3ee1e808e 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -383,7 +383,7 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) } printingProps.insert(attrName, attr.value().toString()); } - PrintingInfo printingInfo(set, LazyPropertiesHash(printingProps)); + PrintingInfo printingInfo(set, LazyPropertiesHash::serializedFrom(printingProps)); _sets[setName].append(printingInfo); } // related cards @@ -450,9 +450,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = - CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, - attributes, std::move(localizedNames), std::move(localizedTexts)); + CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, LazyPropertiesHash::serialize(properties), + relatedCards, reverseRelatedCards, _sets, attributes, {}, {}, + true, std::move(localizedNames), std::move(localizedTexts)); if (targetData) { // Mirror CardDatabase::addCard: if a card with this name already // exists, merge the new printings into it instead of replacing. diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp index 90628224a..3798bcd62 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -1,8 +1,168 @@ #include "lazy_properties_hash.h" #include +#include #include -#include + +namespace +{ +constexpr quint32 NULL_STRING_LENGTH = 0xFFFFFFFFu; + +/** + * @brief Reads a QDataStream-serialized QString length at @p pos, advancing past the length field. + * + * QDataStream writes a QString as a big-endian quint32 byte length followed by UTF-16 code units (also big-endian), + * or 0xFFFFFFFF for a null string. Parsing at the byte level lets us skip and compare properties without + * constructing a QString for every entry. + */ +bool readBlobLength(const QByteArray &blob, int &pos, quint32 &length) +{ + if (pos < 0 || static_cast(pos) + 4 > blob.size()) { + return false; + } + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + length = (quint32(bytes[0]) << 24) | (quint32(bytes[1]) << 16) | (quint32(bytes[2]) << 8) | quint32(bytes[3]); + pos += 4; + + if (length == NULL_STRING_LENGTH) { + length = 0; + return true; + } + + if (length > static_cast(blob.size() - pos)) { + return false; + } + + return true; +} + +/** + * @brief Compares the serialized bytes at @p pos with @p key without allocating a QString. + */ +bool blobKeyEquals(const QByteArray &blob, int pos, quint32 length, const QString &key) +{ + if (length != static_cast(key.size()) * 2) { + return false; + } + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + for (int i = 0; i < key.size(); ++i) { + const ushort codeUnit = static_cast((bytes[2 * i] << 8) | bytes[2 * i + 1]); + if (key.at(i).unicode() != codeUnit) { + return false; + } + } + + return true; +} + +/** + * @brief Builds a QString from the serialized UTF-16BE bytes at @p pos. + */ +QString blobString(const QByteArray &blob, int pos, quint32 length) +{ + const int codeUnits = static_cast(length / 2); + QString result; + result.resize(codeUnits); + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + for (int i = 0; i < codeUnits; ++i) { + result[i] = QChar(static_cast((bytes[2 * i] << 8) | bytes[2 * i + 1])); + } + + return result; +} +} // namespace + +LazyPropertiesHash::BlobReader::BlobReader(const QByteArray &_blob) : blob(_blob) +{ + if (blob.size() < 4) { + return; + } + + const auto *bytes = reinterpret_cast(blob.constData()); + remaining = (quint32(bytes[0]) << 24) | (quint32(bytes[1]) << 16) | (quint32(bytes[2]) << 8) | quint32(bytes[3]); + valid = true; +} + +bool LazyPropertiesHash::BlobReader::next(int &keyPos, quint32 &keyLength, int &valuePos, quint32 &valueLength) +{ + if (!valid || remaining == 0) { + return false; + } + + if (!readBlobLength(blob, pos, keyLength)) { + valid = false; + return false; + } + keyPos = pos; + pos += static_cast(keyLength); + + if (!readBlobLength(blob, pos, valueLength)) { + valid = false; + return false; + } + valuePos = pos; + pos += static_cast(valueLength); + + --remaining; + return true; +} + +quint32 LazyPropertiesHash::BlobReader::entryCount() const +{ + return valid ? remaining : 0; +} + +QString LazyPropertiesHash::blobValue(const QByteArray &blob, const QString &key) +{ + BlobReader reader(blob); + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + if (blobKeyEquals(blob, keyPos, keyLength, key)) { + return blobString(blob, valuePos, valueLength); + } + } + + return {}; +} + +bool LazyPropertiesHash::blobContains(const QByteArray &blob, const QString &key) +{ + BlobReader reader(blob); + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + if (blobKeyEquals(blob, keyPos, keyLength, key)) { + return true; + } + } + + return false; +} + +QStringList LazyPropertiesHash::blobKeys(const QByteArray &blob) +{ + BlobReader reader(blob); + QStringList result; + result.reserve(static_cast(reader.entryCount())); + + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + result.append(blobString(blob, keyPos, keyLength)); + } + + return result; +} LazyPropertiesHash::LazyPropertiesHash() : isMaterialized(true) { @@ -78,10 +238,57 @@ void LazyPropertiesHash::ensureMaterialized() const isMaterialized.store(true, std::memory_order_release); } +QByteArray LazyPropertiesHash::serialize(const QHash &properties) +{ + QByteArray result; + QDataStream out(&result, QIODevice::WriteOnly); + out.setVersion(QDataStream::Qt_6_4); + out << properties; + return result; +} + +LazyPropertiesHash LazyPropertiesHash::serializedFrom(const QHash &properties) +{ + return LazyPropertiesHash(serialize(properties)); +} + QString LazyPropertiesHash::value(const QString &key) const { - ensureMaterialized(); - return properties.value(key); + return read([&key](const QHash &materialized) { return materialized.value(key); }, + [&key](const QByteArray &blob) { return LazyPropertiesHash::blobValue(blob, key); }); +} + +bool LazyPropertiesHash::contains(const QString &key) const +{ + return read([&key](const QHash &materialized) { return materialized.contains(key); }, + [&key](const QByteArray &blob) { return LazyPropertiesHash::blobContains(blob, key); }); +} + +int LazyPropertiesHash::size() const +{ + return read([](const QHash &materialized) { return static_cast(materialized.size()); }, + [](const QByteArray &blob) { return static_cast(BlobReader(blob).entryCount()); }); +} + +bool LazyPropertiesHash::isEmpty() const +{ + return size() == 0; +} + +QStringList LazyPropertiesHash::keys() const +{ + return read([](const QHash &materialized) { return QStringList(materialized.keys()); }, + [](const QByteArray &blob) { return blobKeys(blob); }); +} + +QByteArray LazyPropertiesHash::serialized() const +{ + return read([](const QHash &materialized) { return serialize(materialized); }, + [](const QByteArray &blob) { + // An empty blob is the "unknown/absent" form used by failed cache reads; give callers a + // well-formed serialization of the (empty) property map instead. + return blob.isEmpty() ? serialize({}) : blob; + }); } bool LazyPropertiesHash::insert(const QString &key, const QString &value) diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h index 9e34050e5..c8170b7c2 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -4,20 +4,62 @@ #include #include #include +#include #include +#include #include /** * @brief A property map that can lazily deserialize blobs to avoid loading overhead. * - * Properties are stored as a pre-serialized blob (cheap to load), and the QString is materialized on the first query, - * so the database load avoids constructing thousands of QString per card. + * Properties are stored as a pre-serialized blob (cheap to load), and individual values are read straight out of that + * blob, so the database load and ordinary queries avoid constructing thousands of QString per card. * - * Once the properties are materialized, it cannot be unmaterialized. - * If you want to reset the properties to an unmaterialized state, you should create a new LazyPropertiesHash. + * A full QHash is only built on the first insert() or getProperties() call. Once materialized, it + * cannot be unmaterialized; create a new LazyPropertiesHash to return to the blob form. */ class LazyPropertiesHash { + /** + * @brief Iterates over the key/value pairs of a serialized QHash blob without materializing it. + * + * The blob layout is the one produced by QDataStream for QHash: a quint32 entry count followed by each entry's + * serialized key and value. + */ + class BlobReader + { + public: + explicit BlobReader(const QByteArray &_blob); + + /** + * @brief Reads the next pair's offsets. + * @return False when there are no more pairs or the blob is malformed. + */ + bool next(int &keyPos, quint32 &keyLength, int &valuePos, quint32 &valueLength); + + [[nodiscard]] quint32 entryCount() const; + + private: + const QByteArray &blob; + int pos = 4; + quint32 remaining = 0; + bool valid = false; + }; + + /** + * @brief Looks up @p key in a serialized blob without materializing it. + */ + static QString blobValue(const QByteArray &blob, const QString &key); + + /** + * @brief Checks whether @p key is present in a serialized blob without materializing it. + */ + static bool blobContains(const QByteArray &blob, const QString &key); + + /** + * @brief Lists the keys of a serialized blob without materializing it. + */ + static QStringList blobKeys(const QByteArray &blob); mutable QByteArray blob; ///< Serialized properties (load form). mutable QHash properties; ///< Materialized properties (query form). @@ -30,6 +72,32 @@ class LazyPropertiesHash */ void ensureMaterialized() const; + /** + * @brief Runs the accessor for the current storage form. + * + * The common case skips the mutex once the properties are materialized. While they are + * still in blob form, the mutex is taken and @p onBlob reads the values straight out of + * the blob without building the hash, so ordinary queries stay allocation-light. + * + * @param onMaterialized Called with the materialized hash when it is available. + * @param onBlob Called with the blob when it is still the source of truth. + * @return Whatever the invoked accessor returns; both accessors must return the same type. + */ + template + auto read(MaterializedFn &&onMaterialized, BlobFn &&onBlob) const + { + if (isMaterialized.load(std::memory_order_acquire)) { + return onMaterialized(properties); + } + + QMutexLocker lock(&propertiesMutex); + if (isMaterialized.load(std::memory_order_relaxed)) { + return onMaterialized(properties); + } + + return onBlob(blob); + } + public: /** * @brief Default constructor. @@ -53,12 +121,60 @@ public: LazyPropertiesHash &operator=(const LazyPropertiesHash &other); /** - * @brief Gets the value from the materialized properties hash + * @brief Serializes a property map into the blob form understood by this class. + * @param properties The properties to serialize + * @return The serialized blob + */ + static QByteArray serialize(const QHash &properties); + + /** + * @brief Creates an unmaterialized LazyPropertiesHash from a property map. + * + * Serializes @p properties once so queries read them from the blob form instead of first materializing a second + * copy of the hash. + * + * @param properties The properties to serialize + * @return An unmaterialized LazyPropertiesHash holding the serialized properties + */ + static LazyPropertiesHash serializedFrom(const QHash &properties); + + /** + * @brief Gets the value for a key, reading it from the blob when possible. * @param key The key * @return The value, or an empty string if the key is not present */ QString value(const QString &key) const; + /** + * @brief Checks whether a key is present, reading the blob when possible. + * @param key The key + * @return True if the key is present + */ + bool contains(const QString &key) const; + + /** + * @brief Returns the number of properties. + */ + int size() const; + + /** + * @brief Returns whether there are no properties. + */ + bool isEmpty() const; + + /** + * @brief Returns the property names, reading them from the blob when possible. + */ + QStringList keys() const; + + /** + * @brief Returns the properties in serialized blob form. + * + * While the object is unmaterialized this is a cheap copy of the stored blob; after materialization the hash is + * serialized on demand. + */ + QByteArray serialized() const; + /** * @brief Inserts a value into the materialized properties hash * @param key The key @@ -68,7 +184,7 @@ public: bool insert(const QString &key, const QString &value); /** - * @brief Gets a view of the materialized properties hash. + * @brief Gets a view of the materialized properties hash, materializing it if needed. * @return The properties hash */ const QHash &getProperties() const; diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 9440eb4b0..9bc62a07c 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -12,6 +12,23 @@ void PrintingInfo::setProperty(const QString &_name, const QString &_value) properties.insert(_name, _value); } +bool PrintingInfo::operator==(const PrintingInfo &other) const +{ + if (set != other.set || properties.size() != other.properties.size()) { + return false; + } + + // Both property maps are usually still in blob form here, so compare key by key instead of + // materializing two full hashes just to compare them. + for (const QString &key : properties.keys()) { + if (!other.properties.contains(key) || properties.value(key) != other.properties.value(key)) { + return false; + } + } + + return true; +} + /** * Gets the uuid property of the printing, or an empty string if the property isn't present */ diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index 75f634c3c..dcfcc846b 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -54,10 +54,7 @@ public: * @param other Another PrintingInfo to compare against. * @return True if both set and properties are equal, otherwise false. */ - bool operator==(const PrintingInfo &other) const - { - return this->set == other.set && this->getPropertiesHash() == other.getPropertiesHash(); - } + bool operator==(const PrintingInfo &other) const; /** * @brief check if the info is empty, as if default constructed. @@ -66,7 +63,7 @@ public: */ bool isEmpty() const { - return set == nullptr && getPropertiesHash().isEmpty(); + return set == nullptr && properties.isEmpty(); } private: @@ -91,9 +88,22 @@ public: */ [[nodiscard]] QStringList getProperties() const { - return getPropertiesHash().keys(); + return properties.keys(); } + /** + * @brief Returns the printing properties in their serialized blob form. + */ + [[nodiscard]] QByteArray getPropertiesBlob() const + { + return properties.serialized(); + } + + /** + * @brief Returns the materialized property hash, forcing deserialization of the blob. + * + * Prefer getProperty() or getPropertiesBlob(), which avoid building the hash. + */ [[nodiscard]] const QHash &getPropertiesHash() const { return properties.getProperties(); @@ -107,9 +117,7 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - const auto &propertiesHash = getPropertiesHash(); - const auto it = propertiesHash.constFind(propertyName); - return it != propertiesHash.constEnd() ? it.value() : QString(); + return properties.value(propertyName); } /** diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 3382e36d6..477c54d21 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -486,7 +486,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } - PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps)); + PrintingInfo printingInfo(currentSet, LazyPropertiesHash::serializedFrom(printingProps)); QString numComponent; const QString numProperty = printingInfo.getProperty("num"); diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 2b216d5b6..1b68b8166 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -62,6 +62,43 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } +TEST(CardDatabaseTest, LazyPropertiesHashStreamsBlobAccessors) +{ + QHash props; + props.insert("manacost", "1R"); + props.insert("colors", "R"); + props.insert("format-standard", "legal"); + + LazyPropertiesHash lazy = LazyPropertiesHash::serializedFrom(props); + const QByteArray blob = lazy.serialized(); + + EXPECT_EQ(3, lazy.size()); + EXPECT_FALSE(lazy.isEmpty()); + EXPECT_TRUE(lazy.contains("colors")); + EXPECT_FALSE(lazy.contains("missing")); + EXPECT_EQ("1R", lazy.value("manacost")); + EXPECT_EQ(QString(), lazy.value("missing")); + EXPECT_EQ(blob, lazy.serialized()) << "an untouched blob must be passed through unchanged"; + + QStringList keys = lazy.keys(); + keys.sort(); + QStringList expectedKeys = props.keys(); + expectedKeys.sort(); + EXPECT_EQ(expectedKeys, keys); + + // Mutating materializes the hash, after which every accessor keeps working. + EXPECT_TRUE(lazy.insert("loyalty", "3")); + EXPECT_FALSE(lazy.insert("loyalty", "3")); + EXPECT_EQ(4, lazy.size()); + EXPECT_EQ("3", lazy.value("loyalty")); + EXPECT_TRUE(lazy.contains("loyalty")); + + LazyPropertiesHash roundTrip(lazy.serialized()); + EXPECT_EQ(4, roundTrip.size()); + EXPECT_EQ("3", roundTrip.value("loyalty")); + EXPECT_EQ("legal", roundTrip.value("format-standard")); +} + TEST(CardDatabaseTest, GuessCardWithProviderIdOnUnknownCardReturnsEmpty) { // An unknown name plus a providerId used to dereference the missing card @@ -104,6 +141,8 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); card->setLocalizedName("de", "Blitzschlag"); card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + card->setProperty("format-standard", "legal"); + card->setProperty("colors", "R"); SetNameMap sets; sets.insert("TST", set); @@ -127,6 +166,13 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); + ASSERT_EQ(loaded->getProperty("format-standard"), "legal"); + ASSERT_TRUE(loaded->hasProperty("colors")); + ASSERT_EQ(loaded->getProperties().size(), 2); + + const QList printings = loaded->getSets().value("TST"); + ASSERT_FALSE(printings.isEmpty()); + ASSERT_EQ(printings.first().getProperty("manacost"), "1R"); } /** diff --git a/tests/carddatabase/load_benchmark.cpp b/tests/carddatabase/load_benchmark.cpp index 5492b9531..da077a50f 100644 --- a/tests/carddatabase/load_benchmark.cpp +++ b/tests/carddatabase/load_benchmark.cpp @@ -125,7 +125,7 @@ RunResult runLoad(CardDatabase *db, ChurnCounters &counters) result.churn.modelResets = counters.modelResets - before.modelResets; for (const CardInfoPtr &card : db->getCardList()) { - result.propTotal += card->getPropertiesHash().size(); + result.propTotal += card->getProperties().size(); if (!card->getProperty("colors").isEmpty()) { ++result.propsQueried; } From bbc6003f0a739c8a655e172717ab0a0db7aa9d0a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:46:54 +0200 Subject: [PATCH 098/247] [CrashReporting] Name the files and processes behind a failed installer purge (#7590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CrashReporting] Log which files and processes blocked the installer purge A purge that fails aborts the upgrade with a message that can only guess at the cause, and the log only listed which DLLs survived. Record what Windows reports for each survivor and which processes still have the install directory mapped, then repeat the holder list in the abort message. The diagnostics are additive and run only on the abort path: they open each survivor for DELETE access and close it again without deleting, so a failed upgrade can never end up half-removed. The purge itself is unchanged. * [CrashReporting] Name the holders of a failed purge with Restart Manager The process-and-module scan could only name a holder whose image lives in the install directory or that loaded one of its DLLs, so the case this diagnostic exists for - an antivirus scanner holding a plain handle - was reported as "no holder named, only a code". Reaching the Restart Manager from the NSIS script would have meant marshalling RM_PROCESS_INFO through System::Call as a table of hand-computed offsets, which cannot be checked without a Windows toolchain, and that is why it was dropped when this diagnostics was written. Move the call into tools/installer-holder-probe: a small executable the same Windows build compiles, linking nothing from this project so it still starts when the installed runtime is what the purge could not replace. It rides inside the installer and is extracted to $PLUGINSDIR only when the purge has already failed, so the successful path is unchanged and nothing new is installed. .ci/compile.sh runs it against a file held with FileShare.None on the Windows runners, so the lookup is exercised on real Windows instead of only compiled. * [CrashReporting] Fix the holder probe check and cap the holder line The probe block ran after the package steps, where the working directory is already the build dir, so its $BUILD_DIR-prefixed paths named build/build and the PowerShell file could not be written. Move it ahead of install/package, where the build dir is still the cwd, and name both files relative to it. Guard it on MAKE_NO_CLIENT, since the probe is only built with the client. Cap the rendered holder line at HOLDER_LINE_CHAR_MAX: the count limit alone does not keep long service names inside the NSIS string that also carries the abort text and the log path. Whole entries are kept and the rest counted, with the first holder always shown. Also run cmake-format on the probe's CMakeLists so the lint job accepts it. * [CrashReporting] Report a read-only purge survivor and bound the probe check --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 69 +++++++ CMakeLists.txt | 7 + cmake/NSIS.template.in | 185 ++++++++++++++++++ format.sh | 3 +- tests/CMakeLists.txt | 11 ++ tests/holder_line_test.cpp | 102 ++++++++++ tools/installer-holder-probe/CMakeLists.txt | 21 ++ tools/installer-holder-probe/holder_line.cpp | 64 ++++++ tools/installer-holder-probe/holder_line.h | 59 ++++++ tools/installer-holder-probe/main.cpp | 78 ++++++++ .../restart_manager_holders.cpp | 160 +++++++++++++++ .../restart_manager_holders.h | 35 ++++ 12 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 tests/holder_line_test.cpp create mode 100644 tools/installer-holder-probe/CMakeLists.txt create mode 100644 tools/installer-holder-probe/holder_line.cpp create mode 100644 tools/installer-holder-probe/holder_line.h create mode 100644 tools/installer-holder-probe/main.cpp create mode 100644 tools/installer-holder-probe/restart_manager_holders.cpp create mode 100644 tools/installer-holder-probe/restart_manager_holders.h diff --git a/.ci/compile.sh b/.ci/compile.sh index 63c027473..d60683b6e 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -459,6 +459,75 @@ if [[ $MAKE_COVERAGE ]]; then echo "::endgroup::" fi +if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then + echo "::group::Probe the installer's holder lookup" + # The working directory is still the build dir here: the package steps below are what + # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name + # build/build. Both paths are relative to the dir the probe was built into. + probe="./installer-holder-probe.exe" + if [[ ! -f $probe ]]; then + echo "::error file=$0::installer-holder-probe.exe was not built" + exit 1 + fi + # The probe exists to name a holder that a process-and-module scan cannot see: one + # that has a file open without having loaded it. Holding a temporary DLL with + # FileShare.None is exactly that case, so this asserts the reason the probe was + # added instead of only that the Restart Manager answers at all. It needs no NSIS + # and no packaging, so it runs on every Windows build that gets this far. + probe_check="./holder-probe-check.ps1" + cat >"$probe_check" <<'POWERSHELL' +$ErrorActionPreference = "Stop" +$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID" +Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $dir | Out-Null +$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path + +function Invoke-Probe([string] $guardDir) { + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $probe + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir + $process = [System.Diagnostics.Process]::Start($start) + $read = $process.StandardOutput.ReadToEndAsync() + if (-not $process.WaitForExit(20000)) { + try { $process.Kill() } catch { } + throw "the holder probe did not finish within 20 s for $guardDir" + } + $output = $read.Result + $code = $process.ExitCode + $process.Dispose() + return @{ Code = $code; Output = $output } +} + +try { + $file = Join-Path $dir "locked.dll" + [IO.File]::WriteAllText($file, "x") + $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None") + try { + $locked = 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)" } + $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" +} finally { + Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +} +POWERSHELL + if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then + rm -f "$probe_check" + exit 1 + fi + rm -f "$probe_check" + echo "::endgroup::" +fi + if [[ $MAKE_INSTALL ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" diff --git a/CMakeLists.txt b/CMakeLists.txt index 97b87a1d8..41fe393e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -431,6 +431,13 @@ if(WITH_CLIENT) set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) endif() +if(WIN32 AND WITH_CLIENT) + # Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed + # (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install + # rules and is not part of the client's staged tree. + add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe) +endif() + if(WITH_ORACLE) add_subdirectory(oracle) set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 0ef746430..ae156313d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -28,6 +28,11 @@ Var DataBackupFindHandle Var DataBackupFindName Var IsUpgrade 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 Var WaitPid Var LauncherExited Var InstallLogPath @@ -1171,6 +1176,154 @@ Function CheckStaleRuntimeMask ${EndIf} FunctionEnd +; Turns a Win32 error code into the wording that goes into the log. +; usage: set $0 to the code, result in $R0. +; +; These are Windows' own names for the codes and deliberately nothing more. A +; code on its own cannot say whether an upgrade was blocked by a running instance +; or by a permission: deleting a file whose image is mapped by another process +; surfaces as ACCESS_DENIED, exactly like a file whose ACL denies delete does. +; LogProcessesUsingInstallDir is what separates the two, which is why it runs +; right after this and why the two are read together. +Function GetWin32ErrorText + ${If} $0 = 2 + StrCpy $R0 "already gone" + ${ElseIf} $0 = 3 + StrCpy $R0 "path not found" + ${ElseIf} $0 = 5 + StrCpy $R0 "access denied" + ${ElseIf} $0 = 19 + StrCpy $R0 "write-protected media" + ${ElseIf} $0 = 32 + StrCpy $R0 "sharing violation" + ${ElseIf} $0 = 33 + StrCpy $R0 "lock violation" + ${ElseIf} $0 = 1224 + StrCpy $R0 "file is mapped in memory" + ${Else} + StrCpy $R0 "windows error" + ${EndIf} +FunctionEnd + +; Records which runtime files the purge left behind, what Windows said about each +; one, and which processes are using the install directory. +; +; Diagnostic only, and only on the abort path: it deletes nothing, so it can never +; turn a failed upgrade into a half-removed old install, and it decides nothing - +; the abort that called it is already decided. It exists because NSIS Delete/RMDir +; only set the error flag: without this, a report of "files of a previous version +; survived the purge" said which files were present but never which one was held, +; and never whether the cause was a lock or a permission. +; +; 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. +Function LogStaleRuntimeDiagnostics + FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" + ${If} $StaleFindHandle != "" + stale_diag_loop: + ; ClearErrors tells a plugin failure apart from an API failure: the System + ; plugin sets the error flag only when it could not make the call at all, + ; in which case it pushes its own message instead of the call's value and + ; the pop below must not run. + ClearErrors + System::Call 'kernel32::SetLastError(i 0)' + System::Call 'kernel32::CreateFileW(w "$INSTDIR\$StaleFindName", i 0x00010000, i 0, p 0, i 3, i 0, p 0) p.R1 ?e' + ${If} ${Errors} + StrCpy $R0 "the delete probe could not be run" + ${Else} + Pop $R2 + ${If} $R2 = 0 + ; 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" + ${Else} + 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" + ${EndIf} + ${EndIf} + ${Else} + StrCpy $0 $R2 + Call GetWin32ErrorText + StrCpy $R0 "win32 error $R2: $R0" + ${EndIf} + ${EndIf} + !insertmacro CockLog "stale-runtime: '$StaleFindName' is still present: $R0" + ClearErrors + FindNext $StaleFindHandle $StaleFindName + IfErrors 0 stale_diag_loop + FindClose $StaleFindHandle + ${EndIf} + Call LogProcessesUsingInstallDir +FunctionEnd + +; 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. +; +; 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. +; +; 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. +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 exit code, not the text, decides what happened: it cannot be mangled by what + ; nsExec leaves on the end of the captured output. 1 = the line below names what is + ; holding the files, 0 = nothing holds them any more, anything else = the probe could + ; not answer, and says why in its own output. + ${If} $HolderExit == "1" + StrCpy $HolderFound 1 + ${ElseIf} $HolderExit == "0" + StrCpy $HolderOutput "nothing is holding them any more" + ${Else} + StrCpy $HolderOutput "the holder probe could not run: $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 @@ -1512,6 +1665,26 @@ ${EndIf} ; Remove any runtime DLLs left over from older versions, so a mismatched ; Qt/OpenSSL/protobuf set cannot survive next to the new executables. +; +; A purge that fails is reported with evidence, not only as a survivor list: the +; abort below logs which files survived, what Windows said about each of them and +; which processes still have the install directory mapped (see +; LogStaleRuntimeDiagnostics). +; +; Re-running the running-instance check here was considered and dropped: it would only +; narrow the window the uninstaller ExecWait and the backup/restore copy open between +; .onInit and this point, and on /R it would now abort after the old version has already +; been removed - leaving no install at all. The surviving files are the authority on +; whether anything still holds the directory, and they are measured anyway. +; +; The holder list comes from Restart Manager (RmRegisterResources/RmGetList), the API +; that names every process and service with one of these files open or mapped. It is +; called from tools/installer-holder-probe rather than from this script, because the +; RM_PROCESS_INFO array is a table of hand-computed offsets when it is marshalled +; through the System plugin, and that table is the one thing here that cannot be +; checked without a Windows toolchain. Compiled by the same build that compiles this +; installer, the layout belongs to the Windows SDK, and .ci/compile.sh runs the probe +; on the Windows runners instead of trusting that it compiles. Call IsExistingCockatriceInstall !insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" ; Remembered because the stale-runtime check below must only ever fire for a @@ -1525,6 +1698,11 @@ ${AndIf} $R0 = 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" !insertmacro CockLogListing "after-purge" "*.dll" !insertmacro CockLogListing "after-plugins" "Plugins\*.*" @@ -1549,6 +1727,10 @@ ${EndIf} ${If} $R0 = 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 @@ -1556,6 +1738,9 @@ ${If} $R0 = 1 ; and the exit code. AbortInstall owns that decision. 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." + ${If} $HolderFound = 1 + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" + ${EndIf} Call AbortInstall ${EndIf} diff --git a/format.sh b/format.sh index 9e3a6069b..b4c78543b 100755 --- a/format.sh +++ b/format.sh @@ -19,7 +19,8 @@ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ "cmake/pch" \ -"tests") +"tests" \ +"tools/installer-holder-probe") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aac4d4818..5420c07f5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,6 +178,17 @@ 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. +cockatrice_add_test( + NAME + holder_line_test + SOURCES + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/holder_line.cpp + holder_line_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe +) cockatrice_add_test( NAME playmat_resolver_test diff --git a/tests/holder_line_test.cpp b/tests/holder_line_test.cpp new file mode 100644 index 000000000..8588d8515 --- /dev/null +++ b/tests/holder_line_test.cpp @@ -0,0 +1,102 @@ +#include "holder_line.h" + +#include +#include + +namespace +{ +Holder runningProcess(const std::wstring &imageName, unsigned long processId) +{ + Holder holder; + holder.imageName = imageName; + holder.processId = processId; + return holder; +} + +Holder service(const std::wstring &appName, const std::wstring &shortName, unsigned long processId) +{ + Holder holder; + holder.appName = appName; + holder.serviceShortName = shortName; + holder.processId = processId; + holder.isService = true; + return holder; +} +} // namespace + +TEST(HolderLine, NamesARunningProcessTheWayTaskManagerDoes) +{ + EXPECT_EQ(formatHolder(runningProcess(L"cockatrice.exe", 24340)), + std::wstring(L"cockatrice.exe (pid 24340, running)")); +} + +TEST(HolderLine, NamesAServiceByTheNameTheUserCanSearchFor) +{ + // The image of a service is often a shared host process, so the image name would + // point at the wrong thing; both of the service's own names are kept. + Holder holder = service(L"Sophos Anti-Virus", L"SAVService", 4412); + holder.imageName = L"svchost.exe"; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Sophos Anti-Virus (pid 4412, service SAVService)")); +} + +TEST(HolderLine, FallsBackFromTheImageToTheApplicationName) +{ + // Restart Manager can name a process whose image this one is not allowed to open. + Holder holder; + holder.appName = L"Cockatrice"; + holder.processId = 7; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Cockatrice (pid 7, running)")); +} + +TEST(HolderLine, NamesAnEmptyHolderRatherThanPrintingAnEmptyParenthesis) +{ + Holder holder; + holder.processId = 9; + EXPECT_EQ(formatHolder(holder), std::wstring(L"an unnamed process (pid 9, running)")); +} + +TEST(HolderLine, JoinsHoldersIntoOneLine) +{ + const std::vector holders = { + runningProcess(L"cockatrice.exe", 1), + runningProcess(L"oracle.exe", 2), + }; + EXPECT_EQ(formatHolders(holders), std::wstring(L"cockatrice.exe (pid 1, running); oracle.exe (pid 2, running)")); +} + +TEST(HolderLine, CountsTheHoldersItLeavesOut) +{ + std::vector holders; + for (unsigned long processId = 1; processId <= HOLDER_LINE_MAX + 2; ++processId) { + holders.push_back(runningProcess(L"cockatrice.exe", processId)); + } + const std::wstring line = formatHolders(holders); + // The dialog has to stay readable, but a list that silently stopped at five would + // hide that there is more to close. + EXPECT_NE(line.find(L"pid 5, running"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 6, running"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); +} + +TEST(HolderLine, StopsNamingHoldersBeforeTheLineOutgrowsTheDialog) +{ + // A Windows service name can be 255 characters, and the abort dialog has to fit the + // list next to its own text and the log path in a 1024-character NSIS string. + std::vector holders; + for (unsigned long processId = 1; processId <= 3; ++processId) { + holders.push_back(service(std::wstring(255, L'S'), L"S", processId)); + } + const std::wstring line = formatHolders(holders); + // The first holder is still named in full; the others are counted rather than written + // out until the line overflows. + EXPECT_EQ(line.compare(0, 255, std::wstring(255, L'S')), 0); + EXPECT_NE(line.find(L"(pid 1, service S)"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 2"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); + EXPECT_LE(line.size(), HOLDER_LINE_CHAR_MAX + 20); +} + +TEST(HolderLine, FormatsNothingAsAnEmptyLine) +{ + EXPECT_EQ(formatHolders({}), std::wstring()); +} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt new file mode 100644 index 000000000..3098669cc --- /dev/null +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -0,0 +1,21 @@ +# 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) + +target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) + +# The NSIS template embeds the built file by the same relative path that resolves +# NSIS.definitions.nsh, and that path points at the build root. +set_target_properties( + installer-holder-probe + PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}" +) diff --git a/tools/installer-holder-probe/holder_line.cpp b/tools/installer-holder-probe/holder_line.cpp new file mode 100644 index 000000000..8d438ca17 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.cpp @@ -0,0 +1,64 @@ +#include "holder_line.h" + +#include + +namespace +{ +std::wstring displayNameOf(const Holder &holder) +{ + // A service's application name is its long name, which is what the user can + // search for; its image name is usually a shared host process (svchost.exe) and + // would name the wrong thing. + if (holder.isService && !holder.appName.empty()) { + return holder.appName; + } + if (!holder.imageName.empty()) { + return holder.imageName; + } + if (!holder.appName.empty()) { + return holder.appName; + } + return L"an unnamed process"; +} +} // namespace + +std::wstring formatHolder(const Holder &holder) +{ + std::wstring line = displayNameOf(holder); + line += L" (pid "; + line += std::to_wstring(holder.processId); + if (holder.isService) { + line += L", service "; + line += holder.serviceShortName.empty() ? L"without a short name" : holder.serviceShortName; + } else { + line += L", running"; + } + line += L")"; + return line; +} + +std::wstring formatHolders(const std::vector &holders) +{ + const std::size_t wanted = std::min(holders.size(), HOLDER_LINE_MAX); + std::wstring line; + std::size_t listed = 0; + for (; listed < wanted; ++listed) { + const std::wstring entry = formatHolder(holders[listed]); + const std::size_t separator = line.empty() ? 0 : 2; + // The first holder is always named, however long its name: it is the answer the + // dialog exists to show, and Windows bounds one entry below the whole budget. + if (!line.empty() && line.size() + separator + entry.size() > HOLDER_LINE_CHAR_MAX) { + break; + } + if (!line.empty()) { + line += L"; "; + } + line += entry; + } + if (holders.size() > listed) { + line += L"; and "; + line += std::to_wstring(holders.size() - listed); + line += L" more"; + } + return line; +} diff --git a/tools/installer-holder-probe/holder_line.h b/tools/installer-holder-probe/holder_line.h new file mode 100644 index 000000000..7fcc92375 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.h @@ -0,0 +1,59 @@ +#ifndef HOLDER_LINE_H +#define HOLDER_LINE_H + +#include +#include +#include + +/** + * @brief One application or service that has an installer file open or mapped. + * + * Deliberately free of Windows types: the wording is what the abort dialog shows, + * and it is the only part of the probe that can be exercised off Windows. + */ +struct Holder +{ + std::wstring imageName; ///< "cockatrice.exe", empty when the image could not be read + std::wstring appName; ///< Restart Manager's name for the application or service + std::wstring serviceShortName; ///< service short name, empty when this is not a service + unsigned long processId = 0; + bool isService = false; +}; + +/// How many holders the single-line summary names before it says how many were left out. +inline constexpr std::size_t HOLDER_LINE_MAX = 5; + +/** + * @brief How many characters the summary may spend on the holders it names. + * + * The line is not the whole string: 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. + */ +inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 400; + +/** + * @brief Renders one holder the way the user has to recognise it, e.g. + * "cockatrice.exe (pid 24340, running)". + * + * The image name is preferred over the application name because it is what Task + * Manager shows, and the application name is what remains for a process whose + * image cannot be opened (another account, a protected process). A service is + * named by Restart Manager's long name and identified by its short name, because + * both are how the user finds it again in services.msc. + */ +std::wstring formatHolder(const Holder &holder); + +/** + * @brief Renders every holder as a single "; "-joined line. + * + * One line because it is written to the installer log as one and repeated in a + * message box, where nsExec hands the text back as one string. Only the first + * HOLDER_LINE_MAX are named, and only while they fit in HOLDER_LINE_CHAR_MAX + * characters; the rest are counted, so a machine with a dozen open handles cannot + * push the real answer off the dialog. + */ +std::wstring formatHolders(const std::vector &holders); + +#endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp new file mode 100644 index 000000000..3b5bb5d22 --- /dev/null +++ b/tools/installer-holder-probe/main.cpp @@ -0,0 +1,78 @@ +#include "holder_line.h" +#include "restart_manager_holders.h" + +#include +#include +#include + +namespace +{ +/// The installer sets this, the same variable the running-instance guard uses, instead +/// of passing the directory on the command line: a path with spaces, quotes or a +/// trailing backslash then never has to survive NSIS quoting or a command line. +constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; + +/// 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. +enum ExitCode +{ + EXIT_NO_HOLDERS = 0, + EXIT_HOLDERS_FOUND = 1, + EXIT_NOT_CONFIGURED = 2, + EXIT_PROBE_FAILED = 3, +}; + +/// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes +/// come back to the installer as an ANSI string, so a non-ASCII process name would be +/// mojibake in the log; a '?' at least stays honest. Control characters are dropped +/// too, so a name can never forge a second log line. +void writeLine(const std::wstring &text) +{ + std::string narrow; + narrow.reserve(text.size() + 2); + for (const wchar_t character : text) { + narrow.push_back(character >= 0x20 && character < 0x7F ? static_cast(character) : '?'); + } + narrow += "\r\n"; + DWORD written = 0; + WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); +} + +std::wstring installDirFromEnvironment() +{ + for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { + std::vector buffer(size); + const DWORD length = GetEnvironmentVariableW(INSTALL_DIR_VARIABLE, buffer.data(), size); + if (length == 0) { + return {}; + } + if (length < size) { + return std::wstring(buffer.data(), length); + } + } + return {}; +} +} // namespace + +int main() +{ + const std::wstring installDir = installDirFromEnvironment(); + if (installDir.empty()) { + writeLine(L"the probe was not given the install directory"); + 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; + } + if (holders.empty()) { + // No output: the installer words the "nothing is holding them" case, and it + // owns the message either way. + return EXIT_NO_HOLDERS; + } + writeLine(formatHolders(holders)); + return EXIT_HOLDERS_FOUND; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp new file mode 100644 index 000000000..39f6a0049 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -0,0 +1,160 @@ +#include "restart_manager_holders.h" + +// clang-format off +// restartmanager.h uses the types from windows.h without including it, so the order +// here is a requirement; Regroup would sort them the other way round. +#include +#include +// clang-format on + +#include +#include + +namespace +{ +/// Stop registering after this many files. The install directory carries around 150 +/// files in practice; the cap only exists so a directory someone has filled with files +/// cannot turn the abort path into a long Restart Manager call. +constexpr std::size_t MAX_PROBED_FILES = 2000; + +/// How deep the Plugins walk goes. Its real shape is Plugins\\.dll. +constexpr int MAX_PLUGIN_DEPTH = 4; + +void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) +{ + if (files.size() >= MAX_PROBED_FILES) { + return; + } + WIN32_FIND_DATAW entry{}; + const HANDLE find = FindFirstFileW((directory + L"\\*").c_str(), &entry); + if (find == INVALID_HANDLE_VALUE) { + return; + } + do { + if (files.size() >= MAX_PROBED_FILES) { + break; + } + const std::wstring name = entry.cFileName; + if (name == L"." || name == L".." || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + continue; + } + const std::wstring path = directory + L"\\" + name; + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + if (depth < maxDepth) { + collectFilesOf(path, depth + 1, maxDepth, files); + } + } else { + files.push_back(path); + } + } while (FindNextFileW(find, &entry) != 0); + FindClose(find); +} + +std::wstring imagePathOf(unsigned long processId) +{ + // PROCESS_QUERY_LIMITED_INFORMATION is the right to ask, not to debug: it reads the + // path of a process this one does not own, which is what keeps a SYSTEM-owned holder + // from turning the whole list into an error. One that refuses even this is still + // named, by whatever Restart Manager called it. + const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); + if (process == nullptr) { + return {}; + } + wchar_t path[MAX_PATH * 2] = {}; + DWORD length = static_cast(std::size(path)); + std::wstring imagePath; + if (QueryFullProcessImageNameW(process, 0, path, &length) != 0) { + imagePath.assign(path, length); + } + CloseHandle(process); + return imagePath; +} + +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); +} + +/// Copies a fixed-size, NUL-terminated WCHAR array the API filled in. +template std::wstring fixedString(const wchar_t (&text)[N]) +{ + return std::wstring(text, std::find(text, text + N, L'\0')); +} + +Holder holderOf(const RM_PROCESS_INFO &info) +{ + Holder holder; + holder.processId = info.Process.dwProcessId; + holder.isService = info.ApplicationType == RmService; + holder.appName = fixedString(info.strAppName); + if (holder.isService) { + holder.serviceShortName = fixedString(info.strServiceShortName); + } + holder.imageName = fileNameOf(imagePathOf(holder.processId)); + return holder; +} +} // namespace + +std::vector collectProbeFiles(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); + if (files.size() < MAX_PROBED_FILES) { + collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files); + } + return files; +} + +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders) +{ + holders.clear(); + if (files.empty()) { + return ERROR_SUCCESS; + } + + DWORD session = 0; + wchar_t sessionKey[CCH_RM_SESSION_KEY + 1] = {}; + DWORD error = RmStartSession(&session, 0, sessionKey); + if (error != ERROR_SUCCESS) { + return error; + } + + // One call for the whole set, as the API asks: RmRegisterResources does registry + // work per file and is documented as too expensive to call once per file. + std::vector names; + names.reserve(files.size()); + for (const std::wstring &file : files) { + names.push_back(file.c_str()); + } + error = RmRegisterResources(session, static_cast(names.size()), names.data(), 0, nullptr, 0, nullptr); + + if (error == ERROR_SUCCESS) { + UINT needed = 0; + UINT count = 0; + DWORD rebootReasons = 0; + // The first call asks how large the answer is; Restart Manager reports + // ERROR_MORE_DATA rather than truncating a list that does not fit. + error = RmGetList(session, &needed, &count, nullptr, &rebootReasons); + // Bounded: each retry more than covers the list growing between the two calls, + // and an abort path cannot chase a process that keeps opening files. Giving up + // reports the code, which is a truthful answer instead of a truncated list. + for (int attempt = 0; attempt < 3 && error == ERROR_MORE_DATA; ++attempt) { + count = needed; + std::vector infos(count); + error = RmGetList(session, &needed, &count, infos.data(), &rebootReasons); + if (error == ERROR_SUCCESS) { + holders.reserve(count); + for (UINT index = 0; index < count; ++index) { + holders.push_back(holderOf(infos[index])); + } + } + } + } + + RmEndSession(session); + return error; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h new file mode 100644 index 000000000..d69fc0259 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -0,0 +1,35 @@ +#ifndef RESTART_MANAGER_HOLDERS_H +#define RESTART_MANAGER_HOLDERS_H + +#include "holder_line.h" + +#include +#include + +/** + * @brief Lists the files a failed purge can be holding up, 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. + * + * 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. + */ +std::vector collectProbeFiles(const std::wstring &installDir); + +/** + * @brief Names the applications and services that have one of @p files open or mapped. + * + * Restart Manager reports both a process that loaded one of the files as a module and + * one that merely holds a handle to it, and it reports services, which is the whole + * reason it is used instead of walking every process's module list. + * + * @param holders replaced with one entry per holder, in the order Windows reported them. + * @return 0 when the lookup ran, even if nothing is holding the files; otherwise the + * Win32 code of the Restart Manager call that failed. + */ +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); + +#endif From 1e0fc35d94aff4a0f89229b80f0b11df4b35809f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:26:02 +0200 Subject: [PATCH 099/247] [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers (#7617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- libcockatrice_utility/CMakeLists.txt | 1 + .../libcockatrice/utility/qt_utils.h | 88 +++++++++++++++++-- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 9b21a071f..cc0168cb8 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -27,6 +27,7 @@ set(UTILITY_HEADERS libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h libcockatrice/utility/playmat_params.h + libcockatrice/utility/qt_utils.h libcockatrice/utility/report_categories.h libcockatrice/utility/string_limits.h libcockatrice/utility/dice_limits.h diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 8e5212031..728f1d99e 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,13 +1,29 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include +#include #include #include +#include +#include +#include namespace QtUtils { -template T *findParentOfType(const QObject *obj) +/** + * @brief Returns the nearest ancestor of @p obj that casts to @c T. + * + * Walks the QObject parent chain. QWidget::parentWidget() is the same pointer as + * QObject::parent() for widgets, so this also covers the common "find the parent tab, + * splitter or scroll area" lookups. + * + * @param obj Object to start from. May be null. + * @param includeSelf Also test @p obj itself before walking upwards. + * @return The first matching ancestor, or nullptr if there is none. + */ +template T *findParentOfType(const QObject *obj, bool includeSelf = false) { - const QObject *p = obj ? obj->parent() : nullptr; + const QObject *p = includeSelf ? obj : (obj ? obj->parent() : nullptr); while (p) { if (auto casted = qobject_cast(const_cast(p))) { return casted; @@ -17,22 +33,80 @@ template T *findParentOfType(const QObject *obj) return nullptr; } -static inline void clearLayoutRec(QLayout *l) +/** + * @brief How clearLayoutRec() disposes of the widgets it removes. + * + * Deferred is the safe default: deleteLater() lets a widget finish handling the event that + * triggered the rebuild. Use Immediate when the caller rebuilds the same layout right away + * and a lingering widget would be visible or otherwise in the way until the next event loop + * turn. + */ +enum class DeletePolicy +{ + Deferred, + Immediate +}; + +/** + * @brief Removes and deletes every item in @p l, recursing into nested layouts. + * + * Widgets are disposed of according to @p policy; spacers and nested layouts are simply + * deleted. + */ +static inline void clearLayoutRec(QLayout *l, DeletePolicy policy = DeletePolicy::Deferred) { if (!l) { return; } - QLayoutItem *it; - while ((it = l->takeAt(0)) != nullptr) { + while (QLayoutItem *it = l->takeAt(0)) { if (QWidget *w = it->widget()) { - w->deleteLater(); + if (policy == DeletePolicy::Immediate) { + delete w; + } else { + w->deleteLater(); + } } if (QLayout *sub = it->layout()) { - clearLayoutRec(sub); + clearLayoutRec(sub, policy); } delete it; } } + +/** + * @brief Creates a QScrollArea using the conventions the client applies nearly everywhere: + * the inner widget resizes with the viewport and the horizontal bar is disabled. + * + * @param parent Parent widget, or nullptr for a parentless scroll area. + * @param verticalPolicy Vertical scroll bar policy. + * @param horizontalPolicy Horizontal scroll bar policy. + */ +static inline QScrollArea *makeScrollArea(QWidget *parent = nullptr, + Qt::ScrollBarPolicy verticalPolicy = Qt::ScrollBarAsNeeded, + Qt::ScrollBarPolicy horizontalPolicy = Qt::ScrollBarAlwaysOff) +{ + auto *scrollArea = new QScrollArea(parent); + scrollArea->setWidgetResizable(true); + scrollArea->setVerticalScrollBarPolicy(verticalPolicy); + scrollArea->setHorizontalScrollBarPolicy(horizontalPolicy); + return scrollArea; +} + +/** + * @brief A monospace font at the system UI font's point size. + * + * Used for log dumps, deck codes and other text that has to line up. + */ +static inline QFont monospaceFont() +{ + QFont font(QStringLiteral("monospace")); + font.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + font.setPointSize(systemPointSize); + } + return font; +} } // namespace QtUtils #endif // COCKATRICE_QT_UTILS_H From ab13a6748f499c576b9972b48e5a4d2e5e1dd013 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:26:21 +0200 Subject: [PATCH 100/247] [QtUtils] Adopt shared Qt helpers across the client (#7618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers * [QtUtils] Adopt shared Qt helpers across the client --------- Co-authored-by: Lukas Brübach --- .../game_graphics/zones/view_zone_widget.cpp | 9 +++------ .../widgets/cards/card_info_text_widget.cpp | 5 ++--- .../cards/deck_card_zone_display_widget.cpp | 11 ++--------- .../mana_base/mana_base_widget.cpp | 8 ++------ .../mana_devotion/mana_devotion_widget.cpp | 8 ++------ .../widgets/dialogs/dlg_my_reports.cpp | 11 ++--------- .../widgets/dialogs/dlg_report_user.cpp | 11 ++--------- .../dialogs/dlg_select_set_for_cards.cpp | 16 ++++------------ .../widgets/dialogs/settings_view.cpp | 14 ++++---------- .../general/layout_containers/flow_widget.cpp | 15 +++------------ .../layout_containers/overlap_widget.cpp | 7 ++----- .../quick_settings/settings_popup_widget.cpp | 6 ++---- ...hidekt_api_response_deck_display_widget.cpp | 6 ++---- .../tabs/api/archidekt/tab_archidekt.cpp | 18 ++++-------------- ...pi_response_card_details_display_widget.cpp | 11 ++--------- ...sponse_commander_details_display_widget.cpp | 11 ++--------- ...c_commander_api_response_display_widget.cpp | 6 ++---- ...ommander_api_response_navigation_widget.cpp | 11 ++--------- ...c_top_cards_api_response_display_widget.cpp | 6 ++---- ..._commanders_api_response_display_widget.cpp | 6 ++---- ...ec_top_tags_api_response_display_widget.cpp | 6 ++---- .../src/interface/widgets/tabs/tab_report.cpp | 11 ++--------- .../visual_deck_editor_widget.cpp | 8 ++------ .../deck_preview/deck_preview_widget.h | 11 ++++------- .../visual_deck_storage_widget.cpp | 6 ++---- 25 files changed, 60 insertions(+), 178 deletions(-) diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 49d9955e0..8c82d6401 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -12,6 +12,7 @@ #include "../card_dimensions.h" #include "../z_values.h" #include "libcockatrice/utility/macros.h" +#include "libcockatrice/utility/qt_utils.h" #include "view_zone.h" #include @@ -356,12 +357,8 @@ QRectF ZoneViewWidget::closeButtonRect(QWidget *styleWidget) const QGraphicsView *ZoneViewWidget::findDragView(QWidget *eventWidget) const { - QWidget *current = eventWidget; - while (current) { - if (auto *view = qobject_cast(current)) { - return view; - } - current = current->parentWidget(); + if (auto *view = QtUtils::findParentOfType(eventWidget, true)) { + return view; } if (scene() && !scene()->views().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index fa6cde311..218d0e964 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -33,9 +34,7 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) textLabel->setReadOnly(true); textLabel->setMinimumSize(35, 35); - propsScroll = new QScrollArea(this); - propsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - propsScroll->setWidgetResizable(true); + propsScroll = QtUtils::makeScrollArea(this); propsScroll->setAlignment(Qt::AlignLeft | Qt::AlignTop); propsScroll->setContentsMargins(0, 0, 0, 0); propsScroll->setFrameStyle(QFrame::NoFrame); diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index 911e35285..d7eef6c18 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include class CardSizeWidget; @@ -179,15 +180,7 @@ void DeckCardZoneDisplayWidget::displayCards() void DeckCardZoneDisplayWidget::refreshDisplayType(const DisplayType &_displayType) { displayType = _displayType; - QLayoutItem *item; - while ((item = cardGroupLayout->takeAt(0)) != nullptr) { - if (item->widget()) { - item->widget()->deleteLater(); - } else if (item->layout()) { - item->layout()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(cardGroupLayout); indexToWidgetMap.clear(); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp index fc7914b94..8215cbdad 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -47,12 +48,7 @@ ManaBaseWidget::ManaBaseWidget(QWidget *parent, DeckListStatisticsAnalyzer *anal void ManaBaseWidget::updateDisplay() { // Clear previous widgets - while (QLayoutItem *item = barLayout->takeAt(0)) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(barLayout); auto &pipCount = analyzer->getProductionPipCount(); auto &cardCount = analyzer->getProductionCardCount(); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp index 7e1f0c211..b958a733f 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -47,12 +48,7 @@ ManaDevotionWidget::ManaDevotionWidget(QWidget *parent, DeckListStatisticsAnalyz void ManaDevotionWidget::updateDisplay() { // Clear previous widgets - while (QLayoutItem *item = barLayout->takeAt(0)) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(barLayout); auto &pipCount = analyzer->getDevotionPipCount(); auto &cardCount = analyzer->getDevotionCardCount(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 52c9eaef0..2a2758ba5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -6,8 +6,6 @@ #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include -#include -#include #include #include #include @@ -26,6 +24,7 @@ #include #include #include +#include #include #include @@ -70,13 +69,7 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) auto *chatGroup = new QGroupBox(tr("Chat Log Context")); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index b1ae479fe..c3bd7c0f1 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -5,8 +5,6 @@ #include #include -#include -#include #include #include #include @@ -21,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -92,13 +91,7 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, auto *chatLayout = new QVBoxLayout(chatGroup); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); if (!_autoChatLog.isEmpty()) { chatLogEdit->setPlainText(_autoChatLog); } else { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 57ea09628..660074f94 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -71,10 +72,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de QSplitter *splitter = new QSplitter(Qt::Vertical, this); // Top scroll area - scrollArea = new QScrollArea(this); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); listContainer = new QWidget(scrollArea); listLayout = new QVBoxLayout(listContainer); @@ -90,10 +88,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de leftLayout->setContentsMargins(0, 0, 0, 0); uneditedCardsLabel = new QLabel(this); - uneditedCardsArea = new QScrollArea(this); - uneditedCardsArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - uneditedCardsArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - uneditedCardsArea->setWidgetResizable(true); + uneditedCardsArea = QtUtils::makeScrollArea(this); uneditedCardsFlowWidget = new FlowWidget(uneditedCardsArea, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -109,10 +104,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de rightLayout->setContentsMargins(0, 0, 0, 0); modifiedCardsLabel = new QLabel(this); - modifiedCardsArea = new QScrollArea(this); - modifiedCardsArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - modifiedCardsArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - modifiedCardsArea->setWidgetResizable(true); + modifiedCardsArea = QtUtils::makeScrollArea(this); modifiedCardsFlowWidget = new FlowWidget(modifiedCardsArea, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index ebac4e22f..d22a8d0b3 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include /** @@ -71,10 +72,8 @@ static QScrollArea *makeScrollable(QWidget *widget) { widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); - auto *scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); + auto *scrollArea = QtUtils::makeScrollArea(); scrollArea->setContentsMargins(0, 0, 0, 0); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scrollArea->horizontalScrollBar()->setEnabled(false); scrollArea->setWidget(widget); return scrollArea; @@ -625,13 +624,8 @@ void SettingsView::scrollToAndFlash(QWidget *target) if (!target) { return; } - QWidget *widget = target; - while (widget) { - if (auto *scrollArea = qobject_cast(widget)) { - scrollArea->ensureWidgetVisible(target); - break; - } - widget = widget->parentWidget(); + if (auto *scrollArea = QtUtils::findParentOfType(target, true)) { + scrollArea->ensureWidgetVisible(target); } flashWidget(target); } diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index caeecad86..6b884f890 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include /** * @brief Constructs a FlowWidget. @@ -55,13 +56,9 @@ FlowWidget::FlowWidget(QWidget *parent, // Scroll area (optional) if (useScrollArea) { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, verticalPolicy, horizontalPolicy); scrollArea->setMinimumSize(0, 0); scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - // Set scrollbar policies - scrollArea->setHorizontalScrollBarPolicy(horizontalPolicy); - scrollArea->setVerticalScrollBarPolicy(verticalPolicy); } else { scrollArea = nullptr; } @@ -136,13 +133,7 @@ void FlowWidget::removeWidget(QWidget *widgetToRemove) const void FlowWidget::clearLayout() { if (flowLayout) { - QLayoutItem *item; - while ((item = flowLayout->takeAt(0))) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(flowLayout); } else { // Defensive fallback: recreate the layout if it was deleted externally. flowLayout = new FlowLayout(container, flowDirection); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp index 03240211c..bc2d1cc4d 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp @@ -4,6 +4,7 @@ #include #include +#include /** * @class OverlapWidget @@ -80,11 +81,7 @@ void OverlapWidget::removeWidget(QWidget *widgetToRemove) const void OverlapWidget::clearLayout() { if (overlapLayout != nullptr) { - QLayoutItem *item; - while ((item = overlapLayout->takeAt(0)) != nullptr) { - item->widget()->deleteLater(); - delete item; - } + QtUtils::clearLayoutRec(overlapLayout); } // If layout is null, create a new layout; otherwise, reuse the existing one diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp index 7ace155a7..351e3732c 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include SettingsPopupWidget::SettingsPopupWidget(QWidget *parent) : QWidget(parent, Qt::Popup | Qt::FramelessWindowHint) @@ -54,11 +55,8 @@ void SettingsPopupWidget::adjustSizeToFitScreen() if (contentHeight > maxHeight) { // Create a scroll area and add the container widget to it if not already created if (!scrollArea) { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAlwaysOn); scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // No horizontal scrollbar - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); // Enable vertical scrollbar when needed } // Set maximum height for the scroll area and show vertical scrollbar diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 555ec7ec5..f24f35507 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 @@ -31,6 +31,7 @@ #include #include #include +#include #include #include @@ -66,10 +67,7 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi connect(displayOptionsWidget, &VisualDeckDisplayOptionsWidget::groupCriteriaChanged, this, &ArchidektApiResponseDeckDisplayWidget::onGroupCriteriaChange); - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea = QtUtils::makeScrollArea(this); layout->addWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 02badf1aa..2cd6c78cb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -186,10 +187,7 @@ void TabArchidekt::initializeUi() secondaryToolbarLayout->setSpacing(6); // Scrollable results area - scrollArea = new QScrollArea(container); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea = QtUtils::makeScrollArea(container); resultsContainer = new QWidget(); resultsLayout = new QVBoxLayout(resultsContainer); @@ -599,11 +597,7 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply) // New search → clear everything if (currentPage == 1) { - QLayoutItem *item; - while ((item = resultsLayout->takeAt(0)) != nullptr) { - delete item->widget(); - delete item; - } + QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); @@ -629,11 +623,7 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) isListMode = false; // Clear existing results for single deck view - QLayoutItem *item; - while ((item = resultsLayout->takeAt(0)) != nullptr) { - delete item->widget(); - delete item; - } + QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index 0728b3154..b2ddace01 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWidget( @@ -47,15 +48,7 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi layout->addWidget(backgroundPlateWidget); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { cardPictureWidget->setScaleFactor(parentTab->getCardSizeSlider()->getSlider()->value()); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 3da050edd..2ff22f2b3 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCommanderDetailsDisplayWidget( @@ -33,15 +34,7 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom commanderPicture = new CardInfoPictureWidget(this); commanderPicture->setCard(CardDatabaseManager::query()->getCard({commanderDetails.getName()})); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { connect(parentTab->getCardSizeSlider()->getSlider(), &QSlider::valueChanged, commanderPicture, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp index 111d91b91..2a23ff2a0 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -61,10 +62,7 @@ EdhrecCommanderApiResponseDisplayWidget::EdhrecCommanderApiResponseDisplayWidget } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(splitter); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(splitter); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp index fd02c4261..220dcfbda 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp @@ -7,6 +7,7 @@ #include #include #include +#include EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigationWidget( QWidget *parent, @@ -40,15 +41,7 @@ EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigation layout->addWidget(bracketNavigationWidget, 1, 0, 1, 2); layout->addWidget(budgetNavigationWidget, 2, 0, 1, 2); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { connect(comboPushButton, &QPushButton::clicked, this, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp index 6f32db102..3120b7792 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(QWidget *parent, @@ -29,10 +30,7 @@ EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(Q } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp index 4ab333fd4..5076cd56f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -33,10 +34,7 @@ EdhrecTopCommandersApiResponseDisplayWidget::EdhrecTopCommandersApiResponseDispl } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp index 9c7ce8d06..f679b10e6 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWidget *parent, @@ -29,10 +30,7 @@ EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWi } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 9018e2477..a98dfc242 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -12,8 +12,6 @@ #include #include #include -#include -#include #include #include #include @@ -52,6 +50,7 @@ #include #include #include +#include #include #include @@ -138,13 +137,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T chatGroup = new QGroupBox; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 74b940586..506ffa2b4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, @@ -188,14 +189,9 @@ void VisualDeckEditorWidget::initializeDisplayOptionsAndSearchWidget() void VisualDeckEditorWidget::initializeScrollAreaAndZoneContainer() { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); scrollArea->setMinimumSize(0, 0); - // Set scrollbar policies - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - zoneContainer = new QWidget(scrollArea); zoneContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); zoneContainer->setObjectName("zoneContainer"); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index cdeed87c7..f3fc982ac 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -16,6 +16,7 @@ #include #include #include +#include #include class QFrame; @@ -131,13 +132,9 @@ protected: // If popup is not open, forward event to parent scroll area if (!combo->view()->isVisible()) { // Try to find a scrollable parent and manually send the event - QWidget *parent = combo->parentWidget(); - while (parent) { - if (auto *scroll = qobject_cast(parent)) { - QApplication::sendEvent(scroll->viewport(), event); - return true; // Mark event as handled - } - parent = parent->parentWidget(); + if (auto *scroll = QtUtils::findParentOfType(combo)) { + QApplication::sendEvent(scroll->viewport(), event); + return true; // Mark event as handled } // If no scrollable parent found, just block return true; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 51e9c70e7..efcf19e58 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) @@ -98,10 +99,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare &VisualDeckStorageWidget::updateSelectionAnimationEnabled); // deck area - scrollArea = new QScrollArea(this); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); // putting everything together layout->addWidget(searchAndSortContainer); From e73dd2b52059f38da7a33138485ebfea9dd15091 Mon Sep 17 00:00:00 2001 From: Neel Jogani Date: Sat, 24 Oct 2026 12:04:19 +0530 Subject: [PATCH 101/247] =?UTF-8?q?Refactor=20display=20type=20toggler=20a?= =?UTF-8?q?nd=20add=20language=20change=20handling=20in=20Vis=E2=80=A6=20(?= =?UTF-8?q?#7581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor display type toggler and add language change handling in VisualDeckDisplayOptionsWidget * Address review: extract updateDisplayTypeButton(), document changeEvent * Restore trailing newline in visual_deck_display_options_widget.cpp --- .../visual_deck_display_options_widget.cpp | 39 ++++++++++++------- .../visual_deck_display_options_widget.h | 8 ++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 25ba41aa9..5d7979e15 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -110,12 +111,33 @@ void VisualDeckDisplayOptionsWidget::retranslateUi() sortByLabel->setText(tr("Sort by:")); sortLabel->setText(tr("Click and drag to change the sort order within the groups")); sortCriteriaButton->setToolTip(tr("Configure how cards are sorted within their groups")); - displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + updateDisplayTypeButton(); displayTypeButton->setToolTip( tr("Change how cards are displayed within zones (i.e. overlapped or fully visible.)")); } +void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() +{ + switch (currentDisplayType) { + case DisplayType::Flat: + displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + break; + case DisplayType::Overlap: + displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + break; + } +} + +void VisualDeckDisplayOptionsWidget::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QWidget::changeEvent(event); +} + void VisualDeckDisplayOptionsWidget::onSortCriteriaChange() { QStringList selectedCriteria; @@ -132,17 +154,8 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType() // Toggle the display type currentDisplayType = (currentDisplayType == DisplayType::Overlap) ? DisplayType::Flat : DisplayType::Overlap; - // Update UI and emit signal - switch (currentDisplayType) { - case DisplayType::Flat: - displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); - break; - case DisplayType::Overlap: - displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); - break; - } + // Update only the toggle button, not every string in the widget + updateDisplayTypeButton(); emit displayTypeChanged(currentDisplayType); } diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h index 96600c587..c40b8ffeb 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h @@ -11,6 +11,7 @@ class CompactPushButton; class QComboBox; +class QEvent; class QHBoxLayout; class QLabel; class QListWidget; @@ -57,6 +58,7 @@ public slots: * @brief Updates all UI text for retranslation/localization. * * Called when the application language changes. + * Also refreshes the layout toggle button text for the current display type. */ void retranslateUi(); void updateCompactMode(bool mode); @@ -94,6 +96,10 @@ public: return activeSortCriteria; } +protected: + /** @brief Retranslates on QEvent::LanguageChange. */ + void changeEvent(QEvent *event) override; + private slots: /** * @brief Slot triggered whenever the sort list is reordered. @@ -110,6 +116,8 @@ private slots: void updateDisplayType(); private: + /** @brief Updates the layout toggle button's text and icon for the current display type. */ + void updateDisplayTypeButton(); /** @brief Layout for grouping and sorting UI elements. */ QHBoxLayout *groupAndSortLayout; From 6e770eb7b2413081f5b5ef7d4f50edaa827e217a Mon Sep 17 00:00:00 2001 From: Neel Jogani Date: Sat, 24 Oct 2026 12:05:31 +0530 Subject: [PATCH 102/247] Retranslate Home tab when the language changes (#7579) * Retranslate Home tab when the language changes TabSupervisor::retranslateUi() never included the Home tab, and HomeWidget set its button text only once, when the buttons were created. Switching languages left the Home tab in the old language until it was reopened. Store the Home buttons as members, add HomeWidget::retranslateUi() to set their text, and call it from TabHome and TabSupervisor. The connect button text still only changes for the Connecting, Disconnected and LoggedIn statuses, as before. * Address review: plain ClientStatus, drop optional and placeholder --- .../interface/widgets/general/home_widget.cpp | 51 ++++++++++++++----- .../interface/widgets/general/home_widget.h | 11 +++- .../src/interface/widgets/tabs/tab_home.cpp | 1 + .../interface/widgets/tabs/tab_supervisor.cpp | 1 + 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index c77fd76fd..7555d1f45 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -113,6 +113,32 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) &HomeWidget::updateButtonsToBackgroundColor); } +void HomeWidget::retranslateUi() +{ + visualDeckEditorButton->setText(tr("Create New Deck")); + visualDeckStorageButton->setText(tr("Browse Decks")); + visualDatabaseDisplayButton->setText(tr("Browse Card Database")); + edhrecButton->setText(tr("Browse EDHRec")); + archidektButton->setText(tr("Browse Archidekt")); + replayButton->setText(tr("View Replays")); + if (exitButton) { + exitButton->setText(tr("Quit")); + } + + switch (connectionStatus) { + case StatusDisconnected: + connectButton->setText(tr("Connect")); + break; + case StatusLoggedIn: + connectButton->setText(tr("Play")); + break; + default: + // Connecting, registering, activating, logging in, etc. + connectButton->setText(tr("Connecting...")); + break; + } +} + void HomeWidget::initializeBackgroundFromSource() { // The featured logo is theme/scheme-derived too; reload it alongside the @@ -284,32 +310,32 @@ QGroupBox *HomeWidget::createButtons() updateLogoOverlay(); boxLayout->addSpacing(25); - connectButton = new HomeStyledButton("Connect/Play", gradientColors); + connectButton = new HomeStyledButton(QString(), gradientColors); boxLayout->addWidget(connectButton); - auto visualDeckEditorButton = new HomeStyledButton(tr("Create New Deck"), gradientColors); + visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors); connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); }); boxLayout->addWidget(visualDeckEditorButton); - auto visualDeckStorageButton = new HomeStyledButton(tr("Browse Decks"), gradientColors); + visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors); connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabVisualDeckStorage(true); }); boxLayout->addWidget(visualDeckStorageButton); - auto visualDatabaseDisplayButton = new HomeStyledButton(tr("Browse Card Database"), gradientColors); + visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors); connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addVisualDatabaseDisplayTab); boxLayout->addWidget(visualDatabaseDisplayButton); - auto edhrecButton = new HomeStyledButton(tr("Browse EDHRec"), gradientColors); + edhrecButton = new HomeStyledButton(QString(), gradientColors); connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab); boxLayout->addWidget(edhrecButton); - auto archidektButton = new HomeStyledButton(tr("Browse Archidekt"), gradientColors); + archidektButton = new HomeStyledButton(QString(), gradientColors); connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab); boxLayout->addWidget(archidektButton); - auto replaybutton = new HomeStyledButton(tr("View Replays"), gradientColors); - connect(replaybutton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); - boxLayout->addWidget(replaybutton); + replayButton = new HomeStyledButton(QString(), gradientColors); + connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); + boxLayout->addWidget(replayButton); if (qobject_cast(tabSupervisor->parentWidget())) { - auto exitButton = new HomeStyledButton(tr("Quit"), gradientColors); + exitButton = new HomeStyledButton(QString(), gradientColors); connect(exitButton, &QPushButton::clicked, qobject_cast(tabSupervisor->parentWidget()), &MainWindow::actExit); boxLayout->addWidget(exitButton); @@ -321,20 +347,18 @@ QGroupBox *HomeWidget::createButtons() void HomeWidget::updateConnectButton(const ClientStatus status) { + connectionStatus = status; disconnect(connectButton, &QPushButton::clicked, nullptr, nullptr); switch (status) { case StatusConnecting: - connectButton->setText(tr("Connecting...")); connectButton->setEnabled(false); break; case StatusDisconnected: - connectButton->setText(tr("Connect")); connectButton->setEnabled(true); connect(connectButton, &QPushButton::clicked, qobject_cast(tabSupervisor->parentWidget()), &MainWindow::actConnect); break; case StatusLoggedIn: - connectButton->setText(tr("Play")); connectButton->setEnabled(true); connect(connectButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::switchToFirstAvailableNetworkTab); @@ -342,6 +366,7 @@ void HomeWidget::updateConnectButton(const ClientStatus status) default: break; } + retranslateUi(); } QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index fb9566cad..aa4745c82 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -33,6 +33,7 @@ class HomeWidget : public QWidget public: HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor); void updateRandomCard(); + void retranslateUi(); static QPair extractDominantColors(const QPixmap &pixmap); public slots: @@ -53,7 +54,15 @@ private: DeckList backgroundSourceDeck; QLabel *logoLabel = nullptr; QPair gradientColors; - HomeStyledButton *connectButton; + HomeStyledButton *connectButton = nullptr; + HomeStyledButton *visualDeckEditorButton = nullptr; + HomeStyledButton *visualDeckStorageButton = nullptr; + HomeStyledButton *visualDatabaseDisplayButton = nullptr; + HomeStyledButton *edhrecButton = nullptr; + HomeStyledButton *archidektButton = nullptr; + HomeStyledButton *replayButton = nullptr; + HomeStyledButton *exitButton = nullptr; + ClientStatus connectionStatus = StatusDisconnected; void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index 1358645d0..af9994fdd 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -12,4 +12,5 @@ TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_ void TabHome::retranslateUi() { + homeWidget->retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 2352d7d7b..3d7ca0a98 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -292,6 +292,7 @@ void TabSupervisor::retranslateUi() // tabs QList tabs; + tabs.append(tabHome); tabs.append(tabServer); tabs.append(tabReplays); tabs.append(tabDeckStorage); From 4028606bd9128058a6ab2e957abe966d5778634f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:28:24 +0200 Subject: [PATCH 103/247] [CardDB] Clear CardInfo relations before deferred deletion (#7615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeCard() and swapInDatabaseData() schedule CardRelation deletion with deleteLater(), but the owning CardInfo could outlive them through an external shared pointer and kept returning the freed pointers from getAllRelatedCards(). Add CardInfo::clearDirectRelations() and call it after scheduling deletion so the UI can no longer dereference dangling relations. Co-authored-by: Lukas Brübach --- .../libcockatrice/card/card_info.cpp | 7 +++++ .../libcockatrice/card/card_info.h | 8 ++++++ .../card/database/card_database.cpp | 7 +++++ tests/carddatabase/carddatabase_test.cpp | 27 +++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index c90027442..388c9ab57 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -270,6 +270,13 @@ void CardInfo::resetReverseRelatedCards2Me() reverseRelatedCardsToMe = QList(); } +void CardInfo::clearDirectRelations() +{ + relatedCards = QList(); + reverseRelatedCards = QList(); + reverseRelatedCardsToMe = QList(); +} + // Back-compatibility methods. Remove ASAP QString CardInfo::getCardType() const { diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 77ddce5ce..eb1250a1c 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -453,6 +453,14 @@ public: return result; } void resetReverseRelatedCards2Me(); + /** + * @brief Clears the raw relation lists without deleting the CardRelation objects. + * + * Callers that schedule the relations for deletion (e.g. CardDatabase::removeCard()) + * must use this to drop the now-dangling pointers from this CardInfo, which may + * outlive them through external shared pointers. + */ + void clearDirectRelations(); void addReverseRelatedCards2Me(CardRelation *cardRelation) { reverseRelatedCardsToMe.append(cardRelation); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 7b0d5b2f9..946412606 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -168,6 +168,10 @@ void CardDatabase::removeCard(const CardInfoPtr &card) cardRelation->deleteLater(); } + // The relations are only scheduled for deletion; the CardInfo may outlive + // them through an external shared pointer, so drop the raw pointers now. + card->clearDirectRelations(); + QMutexLocker locker(removeCardMutex); cards.remove(card->getName()); simpleNameCards.remove(card->getSimpleName()); @@ -271,6 +275,9 @@ void CardDatabase::swapInDatabaseData(CardDatabaseData data) for (auto *rel : card->getReverseRelatedCards2Me()) { rel->deleteLater(); } + // Replacement cards take over the database; old CardInfo objects may + // still be referenced by the UI, so clear their dangling relations. + card->clearDirectRelations(); } cards = std::move(data.cards); diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 1b68b8166..eb522434d 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -124,6 +125,32 @@ TEST(CardDatabaseTest, FindPrintingWithIdOnNullCardInfoReturnsEmpty) EXPECT_TRUE(db.query()->findPrintingWithId(nullptr, "card_Not a Card_fixture").isEmpty()); } +TEST(CardInfoTest, ClearDirectRelationsDropsRawPointers) +{ + auto *forward = new CardRelation("Child"); + auto *reverse = new CardRelation("Parent"); + auto *toMe = new CardRelation("Child"); + + auto card = CardInfo::newInstance("Parent", "", false, QHash(), {forward}, {reverse}, + SetToPrintingsMap(), CardInfo::UiAttributes()); + card->addReverseRelatedCards2Me(toMe); + ASSERT_EQ(1, card->getRelatedCards().size()); + ASSERT_EQ(1, card->getReverseRelatedCards().size()); + ASSERT_EQ(1, card->getReverseRelatedCards2Me().size()); + ASSERT_EQ(2, card->getAllRelatedCards().size()); + + card->clearDirectRelations(); + + EXPECT_TRUE(card->getRelatedCards().isEmpty()); + EXPECT_TRUE(card->getReverseRelatedCards().isEmpty()); + EXPECT_TRUE(card->getReverseRelatedCards2Me().isEmpty()); + EXPECT_TRUE(card->getAllRelatedCards().isEmpty()); + + delete forward; + delete reverse; + delete toMe; +} + TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) { NoopCardSetPriorityController controller; From 67a212d67705dd3c46ad3e3743b652274e6e7795 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:40:20 +0200 Subject: [PATCH 104/247] [Theme] Drop stale pixmap caches on palette-only preview (#7600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A palette preview repaints widgets through applyStyleAndPalette without running themeChangedSlot, so generator-owned pixmaps stayed at the previous palette until the next full theme switch. Clear them where the palette is applied. Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_manager.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 9a96b3e45..893cdc795 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -558,6 +558,9 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, widget->update(); } + // Palette-only previews do not run themeChangedSlot(), so drop snapshots + // generated under the previous palette here as well. + clearPixmapGeneratorCaches(); emit paletteChanged(); } From 06b9ca368f44539c08a5630111227b86fb1c6750 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:01:56 +0200 Subject: [PATCH 105/247] [QtUtils] Add layout creation and relayout helpers (#7619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add layout creation and relayout helpers * Swap parameter order (spacing before margin) and remove static from relayout() --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/utility/qt_utils.h | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 728f1d99e..39a9e01e6 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -73,6 +73,40 @@ static inline void clearLayoutRec(QLayout *l, DeletePolicy policy = DeletePolicy } } +/** + * @brief Creates a layout of type @c LayoutT with zero margins. + * + * @param parent Parent widget, or nullptr when the layout is added later. + * @param spacing Optional spacing. Values below zero leave the style's default spacing + * untouched; pass a non-negative value to call setSpacing(). + * @param margin Uniform margin. Defaults to zero. + */ +template LayoutT *makeLayout(QWidget *parent = nullptr, int spacing = -1, int margin = 0) +{ + auto *layout = new LayoutT(parent); + layout->setContentsMargins(margin, margin, margin, margin); + if (spacing >= 0) { + layout->setSpacing(spacing); + } + return layout; +} + +/** + * @brief Forces @p layout to recompute and repaint immediately. + * + * Convenience for the invalidate/activate/update sequence used after changing a layout's + * contents outside of a resize event. + */ +inline void relayout(QLayout *layout) +{ + if (!layout) { + return; + } + layout->invalidate(); + layout->activate(); + layout->update(); +} + /** * @brief Creates a QScrollArea using the conventions the client applies nearly everywhere: * the inner widget resizes with the viewport and the horizontal bar is disabled. From e8060856811791a11f63e0570a77bd392f58d2fa Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:06:18 +0200 Subject: [PATCH 106/247] [QtUtils] Use makeLayout() for zero-margin layouts (#7620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers * [QtUtils] Adopt shared Qt helpers across the client * [QtUtils] Add layout creation and relayout helpers * Swap parameter order (spacing before margin) and remove static from relayout() * [QtUtils] Use relayout() after rebuilding dynamic layouts * [QtUtils] Use makeLayout() for zero-margin layouts * Consolidate setSpacing() calls into makeLayout() using new parameter order --------- Co-authored-by: Lukas Brübach --- cockatrice/src/client/latency_status_widget.cpp | 5 ++--- cockatrice/src/filters/filter_builder.cpp | 4 ++-- .../deckview/deck_view_container.cpp | 4 ++-- .../card_database_update_status_bar.cpp | 4 ++-- .../palette_editor/palette_editor_dialog.cpp | 5 ++--- .../palette_editor/palette_grid_widget.cpp | 4 ++-- .../additional_info/color_identity_widget.cpp | 4 +--- .../cards/additional_info/mana_cost_widget.cpp | 5 ++--- .../widgets/cards/card_info_display_widget.cpp | 4 ++-- .../widgets/cards/card_info_frame_widget.cpp | 10 ++++------ .../mana_curve/mana_curve_category_widget.cpp | 9 +++------ .../widgets/deck_analytics/resizable_panel.cpp | 13 ++++--------- .../deck_editor_filter_dock_widget.cpp | 4 ++-- .../deck_share/shared_deck_preview_widget.cpp | 4 ++-- .../widgets/dialogs/dlg_default_tags_editor.cpp | 4 ++-- .../dialogs/dlg_select_set_for_cards.cpp | 6 ++---- .../general/layout_containers/flow_widget.cpp | 3 +-- .../widgets/onboarding/first_run_wizard.cpp | 4 ++-- .../widgets/onboarding/shader_banner_widget.cpp | 4 ++-- .../printing_selector/card_amount_widget.cpp | 5 ++--- .../printing_selector/printing_selector.cpp | 5 ++--- .../printing_selector_card_overlay_widget.cpp | 5 ++--- ...ame_and_collectors_number_display_widget.cpp | 4 ++-- .../quick_settings/settings_button_widget.cpp | 4 ++-- .../replay/replay_quick_settings_widget.cpp | 4 ++-- .../game_selector_quick_filter_toolbar.cpp | 4 ++-- .../widgets/server/user/user_info_box.cpp | 4 ++-- .../widgets/server/user/user_info_popup.cpp | 5 ++--- .../server/user/user_list_panel_widget.cpp | 5 ++--- .../user_interface_settings_page.cpp | 4 ++-- ...chidekt_api_response_deck_display_widget.cpp | 4 +--- ...t_api_response_deck_entry_display_widget.cpp | 5 ++--- ...pi_response_deck_listings_display_widget.cpp | 8 +++----- .../tabs/api/archidekt/tab_archidekt.cpp | 12 +++--------- .../commander_bracket_widget.cpp | 7 +++---- ...ec_commander_api_response_display_widget.cpp | 4 +--- ...ec_top_cards_api_response_display_widget.cpp | 4 +--- ...p_commanders_api_response_display_widget.cpp | 4 +--- ...rec_top_tags_api_response_display_widget.cpp | 4 +--- .../src/interface/widgets/tabs/tab_game.cpp | 17 ++++++----------- .../src/interface/widgets/tabs/tab_report.cpp | 3 +-- .../tab_deck_storage_visual.cpp | 5 ++--- .../interface/widgets/utility/sequence_edit.cpp | 5 ++--- ...l_database_display_filter_toolbar_widget.cpp | 9 +++------ ...se_display_format_legality_filter_widget.cpp | 4 ++-- ...database_display_main_type_filter_widget.cpp | 4 ++-- ...isual_database_display_set_filter_widget.cpp | 4 ++-- ..._database_display_sub_type_filter_widget.cpp | 4 ++-- .../visual_database_display_widget.cpp | 5 ++--- .../visual_deck_display_options_widget.cpp | 4 +--- .../visual_deck_editor_sample_hand_widget.cpp | 5 ++--- .../deck_preview_deck_tags_display_widget.cpp | 4 ++-- .../deck_preview_tag_item_widget.cpp | 5 ++--- .../deck_preview/public_deck_preview_widget.cpp | 4 ++-- .../visual_deck_storage_search_widget.cpp | 4 ++-- .../visual_deck_storage_sort_widget.cpp | 4 ++-- 56 files changed, 116 insertions(+), 175 deletions(-) diff --git a/cockatrice/src/client/latency_status_widget.cpp b/cockatrice/src/client/latency_status_widget.cpp index 940074581..907a50668 100644 --- a/cockatrice/src/client/latency_status_widget.cpp +++ b/cockatrice/src/client/latency_status_widget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) @@ -24,9 +25,7 @@ LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) latencyGraph = new LatencyGraphWidget(this); latencyGraph->setFixedSize(90, 14); - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(4); + auto *layout = QtUtils::makeLayout(this, 4); layout->addWidget(latencyGraph); layout->addWidget(pingLabel); diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 6cc893224..22679abb1 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) @@ -39,9 +40,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) edit->setClearButtonEnabled(true); edit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - QGridLayout *layout = new QGridLayout; + QGridLayout *layout = QtUtils::makeLayout(); layout->setObjectName("layout"); - layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(typeCombo, 0, 0, 1, 2); layout->addWidget(filterCombo, 0, 2, 1, 2); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index a6e541fca..bd5a0271c 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -111,10 +112,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) connect(deckView, &DeckView::newCardAdded, this, &DeckViewContainer::newCardAdded); connect(deckView, &DeckView::sideboardPlanChanged, this, &DeckViewContainer::sideboardPlanChanged); - deckViewLayout = new QVBoxLayout; + deckViewLayout = QtUtils::makeLayout(); deckViewLayout->addLayout(buttonHBox); deckViewLayout->addWidget(deckView); - deckViewLayout->setContentsMargins(0, 0, 0, 0); setLayout(deckViewLayout); retranslateUi(); diff --git a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp index 721fdd6c8..21e26d073 100644 --- a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp +++ b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace { @@ -32,8 +33,7 @@ QString stageName(CardUpdateStage stage, const QString &fallback) CardDatabaseUpdateStatusBar::CardDatabaseUpdateStatusBar(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); stageLabel = new QLabel(this); progressBar = new QProgressBar(this); diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 20e16614a..b6271291d 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) @@ -57,9 +58,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt void PaletteEditorDialog::setupUi() { - auto *root = new QVBoxLayout(this); - root->setSpacing(0); - root->setContentsMargins(0, 0, 0, 0); + auto *root = QtUtils::makeLayout(this, 0, 0); // Header header = new QWidget; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index de069bc80..9546e0e7e 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -74,8 +75,7 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) refreshChromePalettes(); scroll->setWidget(gridHost); - layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); layout->addWidget(scroll); } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index ef7fb0e0e..0f68f3626 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -23,9 +23,7 @@ ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorIdentity) : QWidget(parent), colorIdentity(_colorIdentity) { - layout = new QHBoxLayout(this); - layout->setSpacing(5); // Small spacing between icons - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 5); layout->setAlignment(Qt::AlignCenter); // Ensure icons are centered setLayout(layout); diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp index 7da97f88c..f6efd267e 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp @@ -11,12 +11,11 @@ #include #include #include +#include ManaCostWidget::ManaCostWidget(QWidget *parent, CardInfoPtr _card) : QWidget(parent), card(_card) { - layout = new QHBoxLayout(this); - layout->setSpacing(5); // Small spacing between icons - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 5); setLayout(layout); setFixedHeight(50); // Fixed height diff --git a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp index 7ec2ee38e..038f08612 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp @@ -17,6 +17,7 @@ #include #include #include +#include class QWidget; @@ -30,9 +31,8 @@ CardInfoDisplayWidget::CardInfoDisplayWidget(const CardRef &cardRef, QWidget *pa text->setObjectName("text"); connect(text, &CardInfoTextWidget::linkActivated, this, [this](const QString &card) { setCard({card}); }); - auto *layout = new QVBoxLayout(); + auto *layout = QtUtils::makeLayout(); layout->setObjectName("layout"); - layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); layout->addWidget(pic, 0, Qt::AlignCenter); layout->addWidget(text, 0, Qt::AlignCenter); diff --git a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp index 0f66eb209..80bc4b9d0 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include struct CardRef; @@ -49,15 +50,13 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) insertTab(ImageAndTextView, tab3, QString()); connect(this, &CardInfoFrameWidget::currentChanged, this, &CardInfoFrameWidget::setViewMode); - tab1Layout = new QVBoxLayout(); + tab1Layout = QtUtils::makeLayout(); tab1Layout->setObjectName("tab1Layout"); - tab1Layout->setContentsMargins(0, 0, 0, 0); tab1Layout->setSpacing(0); tab1->setLayout(tab1Layout); - tab2Layout = new QVBoxLayout(); + tab2Layout = QtUtils::makeLayout(); tab2Layout->setObjectName("tab2Layout"); - tab2Layout->setContentsMargins(0, 0, 0, 0); tab2Layout->setSpacing(0); tab2->setLayout(tab2Layout); @@ -65,9 +64,8 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) splitter->setObjectName("splitter"); splitter->setOrientation(Qt::Vertical); - tab3Layout = new QVBoxLayout(); + tab3Layout = QtUtils::makeLayout(); tab3Layout->setObjectName("tab3Layout"); - tab3Layout->setContentsMargins(0, 0, 0, 0); tab3Layout->setSpacing(0); tab3Layout->addWidget(splitter); tab3->setLayout(tab3Layout); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp index 7e15058a4..be81ab97a 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp @@ -16,14 +16,13 @@ #include #include #include +#include constexpr int MIN_ROW_HEIGHT = 100; // Minimum readable height per row ManaCurveCategoryWidget::ManaCurveCategoryWidget(QWidget *parent) : QWidget(parent) { - layout = new QVBoxLayout(this); - layout->setSpacing(4); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 0, 4); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } @@ -95,9 +94,7 @@ void ManaCurveCategoryWidget::updateDisplay(int minCmc, row->setFixedHeight(MIN_ROW_HEIGHT); - QHBoxLayout *rowLayout = new QHBoxLayout(row); - rowLayout->setContentsMargins(0, 0, 0, 0); - rowLayout->setSpacing(4); + QHBoxLayout *rowLayout = QtUtils::makeLayout(row, 4); QLabel *categoryLabel = new QLabel(cat, row); categoryLabel->setFixedWidth(80); diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index f46679406..85dba6468 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWidget *analyticsPanel, QWidget *parent) @@ -35,18 +36,14 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid { setAcceptDrops(true); - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *mainLayout = QtUtils::makeLayout(this, 0); // Frame for selection highlight frame = new QFrame(this); frame->setFrameShape(QFrame::Box); frame->setLineWidth(2); - auto *frameLayout = new QVBoxLayout(frame); - frameLayout->setContentsMargins(0, 0, 0, 0); - frameLayout->setSpacing(0); + auto *frameLayout = QtUtils::makeLayout(frame, 0); // Add the analytics panel frameLayout->addWidget(analyticsPanel); @@ -63,9 +60,7 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid // Bottom bar with drag button and resize handle auto *bottomBar = new QWidget(frame); - auto *bottomLayout = new QHBoxLayout(bottomBar); - bottomLayout->setContentsMargins(0, 0, 0, 0); - bottomLayout->setSpacing(0); + auto *bottomLayout = QtUtils::makeLayout(bottomBar, 0); // Drag button on the left dragButton = new QPushButton("☰", bottomBar); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 0a47333fa..adeb5c3e1 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include DeckEditorFilterDockWidget::DeckEditorFilterDockWidget(AbstractTabDeckEditor *parent) @@ -80,9 +81,8 @@ void DeckEditorFilterDockWidget::createFiltersDock() filterDelAll->setDefaultAction(aClearFilterAll); filterDelAll->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - auto *filterLayout = new QGridLayout; + auto *filterLayout = QtUtils::makeLayout(); filterLayout->setObjectName("filterLayout"); - filterLayout->setContentsMargins(0, 0, 0, 0); filterLayout->addWidget(filterBuilder, 0, 0, 1, 3); filterLayout->addWidget(filterView, 1, 0, 1, 3); filterLayout->addWidget(filterDelOne, 2, 0, 1, 1); diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp index 3bb829089..ae95ce95c 100644 --- a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, @@ -62,8 +63,7 @@ SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, selectionRow->addWidget(selectionCheckBox); selectionRow->addStretch(1); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addLayout(selectionRow); layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp index 9eb064beb..ee71699d2 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp @@ -17,6 +17,7 @@ #include #include #include +#include DlgDefaultTagsEditor::DlgDefaultTagsEditor(QWidget *parent) : QDialog(parent) { @@ -120,8 +121,7 @@ void DlgDefaultTagsEditor::addItem() // Add new tag auto *item = new QListWidgetItem(listWidget); QWidget *widget = new QWidget(this); - QHBoxLayout *layout = new QHBoxLayout(widget); - layout->setContentsMargins(0, 0, 0, 0); + QHBoxLayout *layout = QtUtils::makeLayout(widget); // Editable tag label QLineEdit *lineEdit = new QLineEdit(newTag, this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 660074f94..1a4f0aeb9 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -84,8 +84,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de // Left container QWidget *leftContainer = new QWidget(this); - QVBoxLayout *leftLayout = new QVBoxLayout(leftContainer); - leftLayout->setContentsMargins(0, 0, 0, 0); + QVBoxLayout *leftLayout = QtUtils::makeLayout(leftContainer); uneditedCardsLabel = new QLabel(this); uneditedCardsArea = QtUtils::makeScrollArea(this); @@ -100,8 +99,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de // Right container QWidget *rightContainer = new QWidget(this); - QVBoxLayout *rightLayout = new QVBoxLayout(rightContainer); - rightLayout->setContentsMargins(0, 0, 0, 0); + QVBoxLayout *rightLayout = QtUtils::makeLayout(rightContainer); modifiedCardsLabel = new QLabel(this); modifiedCardsArea = QtUtils::makeScrollArea(this); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index 6b884f890..d7260912f 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -48,8 +48,7 @@ FlowWidget::FlowWidget(QWidget *parent, setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); } - mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout = QtUtils::makeLayout(this); setLayout(mainLayout); const bool useScrollArea = (horizontalPolicy != Qt::ScrollBarAlwaysOff || verticalPolicy != Qt::ScrollBarAlwaysOff); diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index 426811ec2..39606d167 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -51,8 +52,7 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip); connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext); - auto *headerLayout = new QVBoxLayout; - headerLayout->setContentsMargins(0, 0, 0, 0); + auto *headerLayout = QtUtils::makeLayout(); headerLayout->addWidget(bannerHost); headerLayout->addSpacing(12); headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp index c17399957..1074ff6e5 100644 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -151,8 +152,7 @@ BannerHost::BannerHost(QWidget *parent) : QWidget(parent) { setFixedHeight(150); - stack = new QStackedLayout(this); - stack->setContentsMargins(0, 0, 0, 0); + stack = QtUtils::makeLayout(this); fallback = new GradientFallbackWidget(this); stack->addWidget(fallback); diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp index 4b5040741..eae49d7ef 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include class QModelIndex; @@ -42,9 +43,7 @@ CardAmountWidget::CardAmountWidget(QWidget *parent, : QWidget(parent), deckStateManager(deckStateManager), cardSizeSlider(cardSizeSlider), rootCard(rootCard), zoneName(zoneName), hovered(false) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(10); + layout = QtUtils::makeLayout(this, 10); this->setLayout(layout); this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); layout->setAlignment(Qt::AlignHCenter); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 983d6ba32..b8438134c 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include /** @@ -84,9 +85,7 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck displayOptionsWidget->addSettingsWidget(cardSizeWidget); sortAndOptionsContainer = new QWidget(this); - sortAndOptionsLayout = new QHBoxLayout(sortAndOptionsContainer); - sortAndOptionsLayout->setSpacing(3); - sortAndOptionsLayout->setContentsMargins(0, 0, 0, 0); + sortAndOptionsLayout = QtUtils::makeLayout(sortAndOptionsContainer, 0, 3); sortAndOptionsContainer->setLayout(sortAndOptionsLayout); searchBar = new PrintingSelectorCardSearchWidget(this); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index bec9100a6..94acdbdce 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include class DeckStateManager; @@ -108,9 +109,7 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa : QWidget(parent), deckEditor(_deckEditor), rootCard(_rootCard) { // Set up the main layout - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *mainLayout = QtUtils::makeLayout(this, 0); setLayout(mainLayout); // Add CardInfoPictureWidget diff --git a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp index 9bc92ad90..d8c67c2a9 100644 --- a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include /** @@ -19,8 +20,7 @@ SetNameAndCollectorsNumberDisplayWidget::SetNameAndCollectorsNumberDisplayWidget : QWidget(parent) { // Set up the layout for the widget - layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); layout->setSpacing(1); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index 981ed843c..ea0d2db5a 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) @@ -26,9 +27,8 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); connect(popup, &SettingsPopupWidget::aboutToClose, this, &SettingsButtonWidget::onPopupClosed); - layout = new QHBoxLayout(this); + layout = QtUtils::makeLayout(this); layout->addWidget(button); - layout->setContentsMargins(0, 0, 0, 0); setLayout(layout); } diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp index 5b3829af2..0a64fb4d2 100644 --- a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) { @@ -28,8 +29,7 @@ ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : Settings // putting it all together auto *widget = new QWidget; - auto *grid = new QGridLayout(widget); - grid->setContentsMargins(0, 0, 0, 0); + auto *grid = QtUtils::makeLayout(widget); grid->addWidget(&fastForwardSpeedLabel, 0, 0, 1, 1); grid->addWidget(&fastForwardSpeedBox, 0, 1, 1, 1); grid->addWidget(&skipEmptyCheckBox, 1, 0, 1, 2); diff --git a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp index 0fc3d879c..58f9bb8a9 100644 --- a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp @@ -14,6 +14,7 @@ #include #include #include +#include class TabSupervisor; template class QMap; @@ -24,8 +25,7 @@ GameSelectorQuickFilterToolBar::GameSelectorQuickFilterToolBar(QWidget *parent, const QMap &allGameTypes) : QWidget(parent), tabSupervisor(_tabSupervisor), model(_model) { - mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout = QtUtils::makeLayout(this); mainLayout->setSpacing(5); const GameFilterConfigs &filters = model->getFilters(); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 60bc97edc..4feb73538 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include @@ -53,8 +54,7 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren avatarPic.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); avatarPic.setAlignment(Qt::AlignCenter); - auto *avatarLayout = new QHBoxLayout; - avatarLayout->setContentsMargins(0, 0, 0, 0); + auto *avatarLayout = QtUtils::makeLayout(); avatarLayout->addStretch(1); avatarLayout->addWidget(&avatarPic, 3); avatarLayout->addStretch(1); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index c3e880084..efb3b2e31 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -55,6 +55,7 @@ #include #include #include +#include #include #include @@ -402,9 +403,7 @@ UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, void UserInfoPopup::buildUi() { - auto *root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); + auto *root = QtUtils::makeLayout(this, 0); // Header header = new UserInfoHeaderWidget(this); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp index 78d45d120..5a34c852b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace { @@ -31,9 +32,7 @@ QString sectionKey(UserListWidget::Section section) UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent) : QWidget(parent) { - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(2); + auto *mainLayout = QtUtils::makeLayout(this, 2); searchBar = new QLineEdit(this); searchBar->setClearButtonEnabled(true); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 0a5f57e1f..44ce01aba 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include enum visualDeckStoragePromptForConversionIndex @@ -248,8 +249,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() updateCommanderSpellbookUiState(); - auto *labelLayout = new QHBoxLayout; - labelLayout->setContentsMargins(0, 0, 0, 0); + auto *labelLayout = QtUtils::makeLayout(); labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesLabel); labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesExplainer); diff --git a/cockatrice/src/interface/widgets/tabs/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 f24f35507..3abf3f9aa 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 @@ -238,7 +238,5 @@ void ArchidektApiResponseDeckDisplayWidget::constructZoneWidgetsFromDeckListMode void ArchidektApiResponseDeckDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp index b5e28fcf5..b22dac8de 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -241,9 +242,7 @@ void ArchidektApiResponseDeckEntryDisplayWidget::updateScaledPreview() void ArchidektApiResponseDeckEntryDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } void ArchidektApiResponseDeckEntryDisplayWidget::actRequestNavigationToDeck() diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp index 9c4ea59ab..81619bdcc 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsDisplayWidget( @@ -19,8 +20,7 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD CardSizeWidget *_cardSizeSlider) : QWidget(parent), cardSizeSlider(_cardSizeSlider) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -64,7 +64,5 @@ void ArchidektApiResponseDeckListingsDisplayWidget::append(const ArchidektDeckLi void ArchidektApiResponseDeckListingsDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 2cd6c78cb..60bf00c0a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -81,9 +81,7 @@ void TabArchidekt::initializeUi() { // Main container container = new QWidget(this); - mainLayout = new QVBoxLayout(container); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + mainLayout = QtUtils::makeLayout(container, 0); // Primary toolbar (most important filters) primaryToolbar = new QWidget(container); @@ -106,9 +104,7 @@ void TabArchidekt::initializeUi() // Color filter (inline) QWidget *colorWidget = new QWidget(primaryToolbar); - QHBoxLayout *colorLayout = new QHBoxLayout(colorWidget); - colorLayout->setContentsMargins(0, 0, 0, 0); - colorLayout->setSpacing(2); + QHBoxLayout *colorLayout = QtUtils::makeLayout(colorWidget, 2); QString colorIdentity = "WUBRG"; for (const QChar &color : colorIdentity) { @@ -190,9 +186,7 @@ void TabArchidekt::initializeUi() scrollArea = QtUtils::makeScrollArea(container); resultsContainer = new QWidget(); - resultsLayout = new QVBoxLayout(resultsContainer); - resultsLayout->setContentsMargins(0, 0, 0, 0); - resultsLayout->setSpacing(0); + resultsLayout = QtUtils::makeLayout(resultsContainer, 0); scrollArea->setWidget(resultsContainer); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 06a76dd19..5279165af 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include template class QSharedPointer; @@ -44,8 +45,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); - auto *layout = new QGridLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addWidget(bracketLabel, 0, 0); auto *bracketRow = new QHBoxLayout; @@ -110,8 +110,7 @@ bool CommanderBracketWidget::promptCommanderSpellbookIntegration() // Create label + explainer button auto *labelWidget = new QWidget(&dialog); - auto *labelLayout = new QHBoxLayout(labelWidget); - labelLayout->setContentsMargins(0, 0, 0, 0); + auto *labelLayout = QtUtils::makeLayout(labelWidget); auto *namingLabel = new QLabel(tr("Bracket naming:"), labelWidget); auto *explainerButton = new QToolButton(labelWidget); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp index 2a23ff2a0..6dc987b76 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp @@ -107,9 +107,7 @@ void EdhrecCommanderApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); qDebug() << event->size(); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp index 3120b7792..680926ef4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp @@ -43,9 +43,7 @@ EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(Q void EdhrecTopCardsApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp index 5076cd56f..105386597 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp @@ -48,9 +48,7 @@ void EdhrecTopCommandersApiResponseDisplayWidget::resizeEvent(QResizeEvent *even { QWidget::resizeEvent(event); qDebug() << event->size(); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp index f679b10e6..ae8686f3a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp @@ -43,9 +43,7 @@ EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWi void EdhrecTopTagsApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 4cf5919ba..309720bca 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -78,6 +78,7 @@ #include #include #include +#include #include #include #include @@ -1287,8 +1288,7 @@ void TabGame::createPlayAreaWidget(bool bReplay) connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow); gameView = new GameView(scene); - auto gamePlayAreaVBox = new QVBoxLayout; - gamePlayAreaVBox->setContentsMargins(0, 0, 0, 0); + auto gamePlayAreaVBox = QtUtils::makeLayout(); gamePlayAreaVBox->addWidget(gameView); gamePlayAreaWidget = new QWidget; @@ -1320,8 +1320,7 @@ void TabGame::createDeckViewContainerWidget(bool bReplay) deckViewContainerWidget = new QWidget(); deckViewContainerWidget->setObjectName("deckViewContainerWidget"); - deckViewContainerLayout = new QVBoxLayout; - deckViewContainerLayout->setContentsMargins(0, 0, 0, 0); + deckViewContainerLayout = QtUtils::makeLayout(); deckViewContainerWidget->setLayout(deckViewContainerLayout); } @@ -1336,8 +1335,7 @@ void TabGame::createCardInfoDock(bool bReplay) cardInfoFrameWidget = new CardInfoFrameWidget(); auto cardHInfoLayout = new QHBoxLayout; - auto cardVInfoLayout = new QVBoxLayout; - cardVInfoLayout->setContentsMargins(0, 0, 0, 0); + auto cardVInfoLayout = QtUtils::makeLayout(); cardVInfoLayout->addWidget(cardInfoFrameWidget); cardVInfoLayout->addLayout(cardHInfoLayout); @@ -1364,9 +1362,7 @@ void TabGame::createPlayerListDock(bool bReplay) playerListWidget->setFocusPolicy(Qt::NoFocus); auto *playerListBox = new QWidget(this); - auto *vbox = new QVBoxLayout(playerListBox); - vbox->setContentsMargins(0, 0, 0, 0); - vbox->setSpacing(0); + auto *vbox = QtUtils::makeLayout(playerListBox, 0); vbox->addWidget(playerListWidget); @@ -1391,8 +1387,7 @@ void TabGame::createPlayerListDock(bool bReplay) void TabGame::createMessageDock(bool bReplay) { - auto messageLogLayout = new QVBoxLayout; - messageLogLayout->setContentsMargins(0, 0, 0, 0); + auto messageLogLayout = QtUtils::makeLayout(); // clock if (!bReplay) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index a98dfc242..988c3e7d9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -210,8 +210,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T sgLayout->addWidget(statsTrendLabel); sgLayout->addWidget(statsCategoriesLabel); sgLayout->addWidget(statsDetailText); - auto *statsGroupLayout = new QVBoxLayout(statsGroup); - statsGroupLayout->setContentsMargins(0, 0, 0, 0); + auto *statsGroupLayout = QtUtils::makeLayout(statsGroup); statsGroupLayout->addWidget(statsContent); statsGroup->setChecked(true); connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index cb6dfab9c..74c149b2d 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 @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -46,9 +47,7 @@ TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor, Abstra }); auto *widget = new QWidget(this); - auto *layout = new QVBoxLayout(widget); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); + auto *layout = QtUtils::makeLayout(widget, 0); widget->setLayout(layout); this->setCentralWidget(widget); layout->addWidget(visualDeckStorageWidget); diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index e92287c9a..44e6581e9 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -15,6 +15,7 @@ #include #include #include +#include SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWidget(parent) { @@ -26,9 +27,7 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(1); + auto *layout = QtUtils::makeLayout(this, 1); layout->addWidget(lineEdit); layout->addWidget(clearButton); layout->addWidget(defaultButton); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 3e9d9e525..5063cce14 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include class DeckListModel; @@ -110,14 +111,10 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg void VisualDatabaseDisplayFilterToolbarWidget::initialize() { - auto sortLayout = new QHBoxLayout(sortGroupBox); - sortLayout->setContentsMargins(0, 0, 0, 0); - sortLayout->setSpacing(0); + auto sortLayout = QtUtils::makeLayout(sortGroupBox, 0); sortLayout->setAlignment(Qt::AlignLeft); - auto filterLayout = new QHBoxLayout(filterGroupBox); - filterLayout->setContentsMargins(0, 0, 0, 0); - filterLayout->setSpacing(2); + auto filterLayout = QtUtils::makeLayout(filterGroupBox, 2); filterLayout->setAlignment(Qt::AlignLeft); // create settings widgets diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp index a5725ff3f..3d6a6e56c 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -48,8 +49,7 @@ VisualDatabaseDisplayFormatLegalityFilterWidget::VisualDatabaseDisplayFormatLega layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp index eff0fe10b..1f63a6b01 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWidget(QWidget *parent, @@ -42,8 +43,7 @@ VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWi layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index 3d76fb78a..d15600564 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp @@ -30,13 +30,13 @@ #include #include #include +#include #include VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentSetFilterSettingsWidget(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp index 705bb8efa..26fdce64f 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidget(QWidget *parent, @@ -47,8 +48,7 @@ VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidg layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 1a54ff0f5..ecf59cfc0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include class QMouseEvent; @@ -92,10 +93,8 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, // Set up main layout and widgets setMinimumSize(0, 0); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - mainLayout = new QVBoxLayout(this); + mainLayout = QtUtils::makeLayout(this, 0, 1); setLayout(mainLayout); - mainLayout->setSpacing(1); - mainLayout->setContentsMargins(0, 0, 0, 0); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded); cardSizeWidget = new CardSizeWidget(this, flowWidget, diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 5d7979e15..63ef07835 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -26,9 +26,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) : QWidget(parent) { - groupAndSortLayout = new QHBoxLayout(this); - groupAndSortLayout->setContentsMargins(0, 0, 0, 0); - groupAndSortLayout->setSpacing(3); + groupAndSortLayout = QtUtils::makeLayout(this, 3); groupAndSortLayout->setAlignment(Qt::AlignLeft); this->setLayout(groupAndSortLayout); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index 1554dceb8..91ad63433 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -41,9 +42,7 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare layout->setSpacing(0); setLayout(layout); - auto upperLayout = new QVBoxLayout(); - upperLayout->setContentsMargins(0, 0, 0, 0); - upperLayout->setSpacing(0); + auto upperLayout = QtUtils::makeLayout(nullptr, 0); resetAndHandSizeContainerWidget = new QWidget(this); resetAndHandSizeLayout = new QHBoxLayout(resetAndHandSizeContainerWidget); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index dc619e9fa..4e6e7cc05 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 @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -26,8 +27,7 @@ DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_par setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); // Create layout - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); setFixedHeight(100); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp index 7796f893b..8850060ea 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp @@ -2,13 +2,12 @@ #include #include +#include DeckPreviewTagItemWidget::DeckPreviewTagItemWidget(const QString &tagName, bool isChecked, QWidget *parent) : QWidget(parent), checkBox_(new QCheckBox(this)) { - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(5); + auto *layout = QtUtils::makeLayout(this, 5); checkBox_->setText(tagName); // Set the tag name as the checkbox label checkBox_->setChecked(isChecked); // Set the initial state of the checkbox diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp index 361747423..11a9da66f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry) @@ -36,8 +37,7 @@ PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePu tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); tagsFlowWidget->setSpacing(3, 3); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addWidget(bannerCardDisplayWidget); layout->addWidget(uploadTimeLabel); layout->addWidget(colorIdentityWidget); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 2c03666c1..f2669f55d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include /** * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. @@ -20,8 +21,7 @@ */ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); searchBar = new QLineEdit(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp index f798a72a9..e4285e360 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp @@ -7,11 +7,11 @@ #include #include #include +#include VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); // Initialize the ComboBox From eac149656b1fa2a7072ebf13ab6fafedefd63a82 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:38:54 +0200 Subject: [PATCH 107/247] [QtUtils] Add window fade, path opening, bold font and confirmation helpers (#7621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../libcockatrice/utility/qt_utils.h | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 39a9e01e6..1d06c4d72 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,11 +1,15 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include #include #include #include +#include #include +#include #include #include +#include #include namespace QtUtils @@ -141,6 +145,64 @@ static inline QFont monospaceFont() } return font; } + +/** + * @brief Fades @p window between two opacity values. + * + * @param window Window to animate. + * @param startOpacity Starting opacity. + * @param endOpacity Ending opacity. + * @param durationMs Duration in milliseconds. + * @param hideWhenFinished Hide the window when the animation finishes. + * @return The running animation. It is owned by @p window and deletes itself when stopped. + */ +static inline QPropertyAnimation * +fadeWindow(QWidget *window, qreal startOpacity, qreal endOpacity, int durationMs, bool hideWhenFinished = false) +{ + auto *animation = new QPropertyAnimation(window, "windowOpacity", window); + animation->setDuration(durationMs); + animation->setStartValue(startOpacity); + animation->setEndValue(endOpacity); + if (hideWhenFinished) { + QObject::connect(animation, &QPropertyAnimation::finished, window, &QWidget::hide); + } + animation->start(QAbstractAnimation::DeleteWhenStopped); + return animation; +} + +/** + * @brief Opens @p path with the platform's file manager or default handler. + */ +static inline bool openPathInFileManager(const QString &path) +{ + return QDesktopServices::openUrl(QUrl::fromLocalFile(path)); +} + +/** + * @brief Returns @p font with bold styling enabled. + */ +static inline QFont boldFont(QFont font) +{ + font.setBold(true); + return font; +} + +/** + * @brief Shows a Yes/No confirmation dialog and returns true when the user confirms. + * + * @param parent Parent widget. + * @param title Dialog title. + * @param text Dialog text. + * @param defaultButton Button focused by default. NoButton lets Qt choose. + */ +static inline bool confirm(QWidget *parent, + const QString &title, + const QString &text, + QMessageBox::StandardButton defaultButton = QMessageBox::NoButton) +{ + return QMessageBox::question(parent, title, text, QMessageBox::Yes | QMessageBox::No, defaultButton) == + QMessageBox::Yes; +} } // namespace QtUtils #endif // COCKATRICE_QT_UTILS_H From 11e2bb0d2f93317d8371d3191b784a398e110e45 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:43:50 +0200 Subject: [PATCH 108/247] [QtUtils] Adopt window fade, path, bold font and confirmation helpers (#7622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 6 +++--- .../deckview/deck_view_container.cpp | 4 +--- .../intents/intent_open_shared_deck.cpp | 13 ++++++------- cockatrice/src/interface/intents/url_parser.cpp | 14 ++++++-------- .../palette_editor/palette_grid_widget.cpp | 8 ++------ .../dialogs/dlg_convert_deck_to_cod_format.cpp | 8 +++----- .../widgets/dialogs/dlg_report_user.cpp | 8 ++------ .../widgets/dialogs/dlg_shared_decks_preview.cpp | 4 ++-- .../src/interface/widgets/dialogs/dlg_update.cpp | 16 ++++++---------- .../dialogs/override_printing_warning.cpp | 6 ++---- .../widgets/onboarding/first_run_wizard.cpp | 3 +-- .../interface/widgets/server/game_selector.cpp | 7 +++---- .../widgets/server/user/user_context_menu.cpp | 7 +++---- .../widgets/server/user/user_list_widget.cpp | 16 +++------------- .../settings_page/appearance_settings_page.cpp | 5 ++--- .../settings_page/general_settings_page.cpp | 6 ++---- .../settings_page/shortcut_settings_page.cpp | 8 ++++---- .../interface/widgets/tabs/tab_deck_storage.cpp | 5 ++--- .../src/interface/widgets/tabs/tab_game.cpp | 13 ++++++------- .../interface/widgets/tabs/tab_public_decks.cpp | 5 ++--- .../src/interface/widgets/tabs/tab_replays.cpp | 5 ++--- .../interface/widgets/tabs/tab_supervisor.cpp | 6 +++--- .../widgets/utility/card_completer_delegate.cpp | 4 ++-- .../widgets/utility/card_completer_styler.cpp | 8 ++------ .../visual_database_filter_display_widget.cpp | 7 +++---- cockatrice/src/interface/window_main.cpp | 11 +++++------ 26 files changed, 78 insertions(+), 125 deletions(-) diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 037565c6a..e23e91b46 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include class QWidget; @@ -300,9 +301,8 @@ void ConnectionController::onLoginError(int r, } case Response::RespRegistrationRequired: { - if (QMessageBox::question(dialogParent, tr("Error"), - tr("This server requires user registration. Do you want to register now?"), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + if (QtUtils::confirm(dialogParent, tr("Error"), + tr("This server requires user registration. Do you want to register now?"))) { registerToServer(); } return; // don't re-prompt connect diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index bd5a0271c..90b3bea11 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -442,9 +442,7 @@ void DeckViewContainer::readyStart() void DeckViewContainer::forceStart() { const auto msg = tr("Are you sure you want to force start?\nThis will kick all non-ready players from the game."); - const auto res = - QMessageBox::question(this, tr("Cockatrice"), msg, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (res == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Cockatrice"), msg, QMessageBox::Yes)) { return; } diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp index 8abad3d88..2998ff78e 100644 --- a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -184,13 +185,11 @@ void IntentOpenSharedDeck::onItemFailure(const QString &reason) } const int downloadedCount = loadedDecks.size(); - const QMessageBox::StandardButton answer = QMessageBox::question( - previewDialog, tr("Open shared decks"), - tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) - .arg(itemNames.value(currentItemId, tr("Unknown deck"))), - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - - if (answer == QMessageBox::Yes) { + if (QtUtils::confirm(previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", + downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes)) { finishAll(); } else { previewDialog->deleteLater(); diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 966fd1213..1e482a4f1 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -115,9 +116,7 @@ Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingInt const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); const QString message = generateJoinGameMessage(*ctx, gameDescription); - const QMessageBox::StandardButton answer = QMessageBox::question( - mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm(mainWindow, tr("Join game"), message, QMessageBox::Yes)) { return nullptr; } @@ -231,11 +230,10 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingInt if (client->getStatus() == StatusLoggedIn) { const QString current = QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort())); - const QMessageBox::StandardButton answer = QMessageBox::question( - mainWindow, tr("Open shared deck"), - tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm( + mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), + QMessageBox::Yes)) { return nullptr; } chain.migrationTargetHost = ctx->serverContext.hostname; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 9546e0e7e..dc61a0099 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -102,9 +102,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *label = new QLabel(groupHeaders[col], host); label->setAlignment(Qt::AlignCenter); label->setToolTip(groupTips[col]); - QFont f = label->font(); - f.setBold(true); - label->setFont(f); + label->setFont(QtUtils::boldFont(label->font())); label->setAutoFillBackground(true); label->setContentsMargins(4, 4, 4, 4); grid->addWidget(label, 0, col + 1); @@ -149,9 +147,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *appHeader = new QLabel(tr("App colors"), host); appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); - QFont appHeaderFont = appHeader->font(); - appHeaderFont.setBold(true); - appHeader->setFont(appHeaderFont); + appHeader->setFont(QtUtils::boldFont(appHeader->font())); appHeader->setAutoFillBackground(true); appHeader->setContentsMargins(4, 4, 4, 4); grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index f2dd8c1e4..31841b9cd 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 @@ -13,6 +13,7 @@ #include #include #include +#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -57,11 +58,8 @@ bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); if (QFile::exists(newFileName)) { - QMessageBox::StandardButton reply = - QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), - QObject::tr("A .cod version of this deck already exists. Overwrite it?"), - QMessageBox::Yes | QMessageBox::No); - return reply == QMessageBox::Yes; + return QtUtils::confirm(parent, QObject::tr("Overwrite Existing File?"), + QObject::tr("A .cod version of this deck already exists. Overwrite it?")); } return true; // Safe to proceed } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index c3bd7c0f1..3ef45020b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -128,12 +128,8 @@ void DlgReportUser::actSubmit() return; } - QMessageBox::StandardButton reply = - QMessageBox::question(this, tr("Confirm Report"), - tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()), - QMessageBox::Yes | QMessageBox::No); - - if (reply != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Confirm Report"), + tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()))) { return; } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp index 8ad91dcaf..d29aee27f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include class QWidget; @@ -34,8 +35,7 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, // QLabels render AutoText and markup would otherwise be shown as rich text. auto *titleLabel = new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); - QFont titleFont = titleLabel->font(); - titleFont.setBold(true); + QFont titleFont = QtUtils::boldFont(titleLabel->font()); titleFont.setPointSize(titleFont.pointSize() + 2); titleLabel->setFont(titleFont); mainLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 76bb13248..5ec8dcf16 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -170,16 +171,11 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas publishDate = release->getPublishDate().toString(QLocale().dateFormat(QLocale::LongFormat)); if (isCompatible) { - int reply; - reply = QMessageBox::question( - window(), tr("Update Available"), - tr("A new version of Cockatrice is available!") + "

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

" + tr("Do you want to update now?"), - QMessageBox::Yes | QMessageBox::No); - - if (reply == QMessageBox::Yes) { + if (QtUtils::confirm(window(), tr("Update Available"), + tr("A new version of Cockatrice is available!") + "

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

" + tr("Do you want to update now?"))) { downloadUpdate(release->getName()); } else { closeDialog(); diff --git a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp index 57f7ff7f5..8230dc3bc 100644 --- a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp +++ b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp @@ -7,6 +7,7 @@ #include #include #include +#include class QWidget; @@ -30,10 +31,7 @@ bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) "Are you sure you would like to disable this feature?"); } - QMessageBox::StandardButton result = - QMessageBox::question(parent, QObject::tr("Confirm Change"), message, QMessageBox::Yes | QMessageBox::No); - - if (result == QMessageBox::Yes) { + if (QtUtils::confirm(parent, QObject::tr("Confirm Change"), message)) { SettingsCache::instance().cardsDisplay().setOverrideAllCardArtWithPersonalPreference(enable); // Caches are now invalid. CardPictureLoader::clearPixmapCache(); diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index 39606d167..c4298d6b6 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -32,9 +32,8 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) bannerHost = new BannerHost(this); titleLabel = new QLabel(this); - QFont titleFont = titleLabel->font(); + QFont titleFont = QtUtils::boldFont(titleLabel->font()); titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); - titleFont.setBold(true); titleLabel->setFont(titleFont); subtitleLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 6922bfe64..2702a527d 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -420,10 +421,8 @@ void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, // spectator join, so ask first instead of surprising the player. const bool gameFull = game.player_count() == game.max_players(); if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) { - const QMessageBox::StandardButton answer = - QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), + QMessageBox::No)) { return; } } diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index c8fe97cda..0b5523f67 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include @@ -685,10 +686,8 @@ void UserContextMenu::execRemoveFromIgnore(const QString &userName) void UserContextMenu::execKick(int playerId) { - auto result = QMessageBox::question(static_cast(parent()), tr("Kick Player"), - tr("Are you sure you want to kick this player from the game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (result == QMessageBox::Yes) { + if (QtUtils::confirm(static_cast(parent()), tr("Kick Player"), + tr("Are you sure you want to kick this player from the game?"), QMessageBox::No)) { Command_KickFromGame cmd; cmd.set_player_id(playerId); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index b3fd1d1a9..efbdcfb23 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -13,7 +13,6 @@ #include "user_list_painter.h" #include "user_list_proxy.h" -#include #include #include #include @@ -41,7 +40,6 @@ #include #include #include -#include #include #include #include @@ -58,6 +56,7 @@ #include #include #include +#include #include class QPixmap; @@ -895,11 +894,7 @@ void UserListWidget::showPopupForUser(UserListTWI *item) return; } - auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); - fade->setDuration(120); - fade->setStartValue(0.0); - fade->setEndValue(1.0); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(userInfoPopup, 0.0, 1.0, 120); } void UserListWidget::positionPopup(UserListTWI *item) @@ -976,12 +971,7 @@ void UserListWidget::hidePopup(bool immediate) } // Fade out - auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); - fade->setDuration(100); - fade->setStartValue(userInfoPopup->windowOpacity()); - fade->setEndValue(0.0); - connect(fade, &QPropertyAnimation::finished, userInfoPopup, &QWidget::hide); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(userInfoPopup, userInfoPopup->windowOpacity(), 0.0, 100, true); } bool UserListWidget::isPressInsideListUi(const QWidget *widget) const diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index be2d5735e..de5f67b32 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -30,7 +29,6 @@ #include #include #include -#include #include #include #include @@ -39,6 +37,7 @@ #include #include #include +#include #include AppearanceSettingsPage::AppearanceSettingsPage() @@ -446,7 +445,7 @@ void AppearanceSettingsPage::openThemeLocation() dirDir.cdUp(); // open if dir exists, create if parent dir does exist if (dirDir.exists() && dirDir.mkpath(dir)) { - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } else { QMessageBox::critical(this, tr("Error"), tr("Could not create themes directory at '%1'.").arg(dir)); } diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index a19417661..2b885237b 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -547,12 +548,9 @@ void GeneralSettingsPage::cardLanguageBoxChanged(int index) } message += tr("

Run Oracle now?

"); - const QMessageBox::StandardButton answer = QMessageBox::question( - this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - // The answer only controls whether Oracle starts right away; the caches stay // cleared so the next import or launch rebuilds them in the new language. - if (answer == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Card text & images language changed"), message, QMessageBox::Yes)) { emit cardDatabaseUpdateRequested(); } } diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index ea7ed745c..a77c0c2c3 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include ShortcutSettingsPage::ShortcutSettingsPage() @@ -112,16 +113,15 @@ void ShortcutSettingsPage::currentItemChanged(const QString &key) void ShortcutSettingsPage::resetShortcuts() { - if (QMessageBox::question(this, tr("Restore all default shortcuts"), - tr("Do you really want to restore all default shortcuts?")) == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Restore all default shortcuts"), + tr("Do you really want to restore all default shortcuts?"))) { SettingsCache::instance().shortcuts().resetAllShortcuts(); } } void ShortcutSettingsPage::clearShortcuts() { - if (QMessageBox::question(this, tr("Clear all default shortcuts"), - tr("Do you really want to clear all shortcuts?")) == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Clear all default shortcuts"), tr("Do you really want to clear all shortcuts?"))) { SettingsCache::instance().shortcuts().clearAllShortcuts(); } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 44d04bd24..9db1952f1 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -20,7 +20,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,6 @@ #include #include #include -#include #include #include #include @@ -65,6 +63,7 @@ #include #include #include +#include #include #include #include @@ -524,7 +523,7 @@ void TabDeckStorage::actDeleteLocalDeck() void TabDeckStorage::actOpenDecksFolder() { QString dir = localDirModel->rootPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 309720bca..11813cdf4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -606,15 +606,14 @@ void TabGame::actConcede() return; } if (!player->getConceded()) { - if (QMessageBox::question(this, tr("Concede"), tr("Are you sure you want to concede this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Concede"), tr("Are you sure you want to concede this game?"), + QMessageBox::No)) { return; } emit game->getPlayerManager()->activeLocalPlayerConceded(); } else { - if (QMessageBox::question(this, tr("Unconcede"), - tr("You have already conceded. Do you want to return to this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Unconcede"), + tr("You have already conceded. Do you want to return to this game?"), QMessageBox::No)) { return; } emit game->getPlayerManager()->activeLocalPlayerUnconceded(); @@ -631,8 +630,8 @@ bool TabGame::leaveGame() if (!game->getGameState()->isGameClosed()) { if (!game->getPlayerManager()->isSpectator()) { tabSupervisor->setCurrentWidget(this); - if (QMessageBox::question(this, tr("Leave game"), tr("Are you sure you want to leave this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Leave game"), tr("Are you sure you want to leave this game?"), + QMessageBox::No)) { return false; } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index 6bf3f6be7..9c28abd0b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include @@ -44,9 +45,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); - QFont titleFont = titleLabel->font(); - titleFont.setBold(true); - titleLabel->setFont(titleFont); + titleLabel->setFont(QtUtils::boldFont(titleLabel->font())); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 52bc4cd8f..d37bff3d8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,6 @@ #include #include #include -#include #include #include #include @@ -61,6 +59,7 @@ #include #include #include +#include #include #include #include @@ -400,7 +399,7 @@ void TabReplays::actDeleteLocalReplay() void TabReplays::actOpenReplaysFolder() { QString dir = localDirModel->rootPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void TabReplays::actRemoteDoubleClick(const QModelIndex &curRight) diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3d7ca0a98..8ec76aaf0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -79,6 +79,7 @@ #include #include #include +#include #include #include #include @@ -363,9 +364,8 @@ void TabSupervisor::closeEvent(QCloseEvent *event) QTabWidget::closeEvent(event); if (getGameCount()) { - if (QMessageBox::question(this, tr("Are you sure?"), - tr("There are still open games. Are you sure you want to quit?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Are you sure?"), + tr("There are still open games. Are you sure you want to quit?"), QMessageBox::No)) { event->ignore(); return; } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index af07590a8..a629df43c 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include class QObject; @@ -181,9 +182,8 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt p->setBrush(Qt::NoBrush); p->drawEllipse(pip.adjusted(1, 1, -1, -1)); - QFont f = p->font(); + QFont f = QtUtils::boldFont(p->font()); f.setPixelSize(qMax(radius - 1, 7)); - f.setBold(true); p->setFont(f); p->setPen(mc.text); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 5b33303a0..8f1941412 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include template class QSharedPointer; @@ -346,12 +347,7 @@ void CardCompleterStyler::showPreview() preview->show(); preview->raise(); - auto *fade = new QPropertyAnimation(preview, "windowOpacity", preview); - - fade->setDuration(FadeDuration); - fade->setStartValue(preview->windowOpacity()); - fade->setEndValue(1.0); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(preview, preview->windowOpacity(), 1.0, FadeDuration); } // --------------------------------------------------------------------------- diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp index 0f6ac51ec..d13db1273 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include FilterDisplayWidget::FilterDisplayWidget(QWidget *parent, const QString &filename, FilterTreeModel *_filterModel) @@ -69,10 +70,8 @@ void FilterDisplayWidget::loadFilter() void FilterDisplayWidget::deleteFilter() { // Show confirmation dialog before deleting the filter - QMessageBox::StandardButton reply = QMessageBox::question( - this, tr("Confirm Delete"), tr("Are you sure you want to delete the filter '%1'?").arg(filterFilename), - QMessageBox::Yes | QMessageBox::No); - if (reply == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Confirm Delete"), + tr("Are you sure you want to delete the filter '%1'?").arg(filterFilename))) { // If confirmed, delete the filter QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filterFilename; QFile file(filePath); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 636cee281..a3446e42b 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -62,7 +62,6 @@ #include #include #include -#include #include #include #include @@ -112,6 +111,7 @@ #include #include #include +#include #include #include #include @@ -341,7 +341,7 @@ void MainWindow::actViewLog() void MainWindow::actOpenSettingsFolder() { QString dir = SettingsCache::instance().getSettingsPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::retranslateUi() @@ -937,8 +937,7 @@ void MainWindow::closeEvent(QCloseEvent *event) : tr("A card database update is still running: %1.\nQuitting now will cancel it.\n" "Are you sure you want to quit?") .arg(runningFor); - if (QMessageBox::question(this, tr("Are you sure?"), message, QMessageBox::Yes | QMessageBox::No, - QMessageBox::No) == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Are you sure?"), message, QMessageBox::No)) { event->ignore(); bClosingDown = false; return; @@ -1491,13 +1490,13 @@ void MainWindow::refreshShortcuts() void MainWindow::actOpenCustomFolder() { QString dir = SettingsCache::instance().paths().getCustomPicsPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::actOpenCustomsetsFolder() { QString dir = SettingsCache::instance().getCustomCardDatabasePath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::actAddCustomSet() From 28c8474f033fee859617ddb72c943c283634cf2d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:43:10 +0200 Subject: [PATCH 109/247] [QtUtils] Add Debouncer helper (#7623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add Debouncer helper * Extract long message to variable before confirm() call --------- Co-authored-by: Lukas Brübach --- .../interface/widgets/dialogs/dlg_update.cpp | 12 ++-- libcockatrice_utility/CMakeLists.txt | 1 + .../libcockatrice/utility/debouncer.h | 62 +++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 libcockatrice_utility/libcockatrice/utility/debouncer.h diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 5ec8dcf16..ca55c7b41 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -171,11 +171,13 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas publishDate = release->getPublishDate().toString(QLocale().dateFormat(QLocale::LongFormat)); if (isCompatible) { - if (QtUtils::confirm(window(), tr("Update Available"), - tr("A new version of Cockatrice is available!") + "

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

" + tr("Do you want to update now?"))) { + const QString message = tr("A new version of Cockatrice is available!") + "

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

" + tr("Do you want to update now?"); + + if (QtUtils::confirm(window(), tr("Update Available"), message)) { downloadUpdate(release->getName()); } else { closeDialog(); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index cc0168cb8..748f6b90f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -21,6 +21,7 @@ set(UTILITY_HEADERS libcockatrice/utility/card_ref.h libcockatrice/utility/color.h libcockatrice/utility/cryptoutil.h + libcockatrice/utility/debouncer.h libcockatrice/utility/expression.h libcockatrice/utility/levenshtein.h libcockatrice/utility/local_server_name.h diff --git a/libcockatrice_utility/libcockatrice/utility/debouncer.h b/libcockatrice_utility/libcockatrice/utility/debouncer.h new file mode 100644 index 000000000..54635db4f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/debouncer.h @@ -0,0 +1,62 @@ +#ifndef COCKATRICE_DEBOUNCER_H +#define COCKATRICE_DEBOUNCER_H + +#include +#include +#include +#include + +namespace QtUtils +{ +/** + * @brief Runs the most recent call() after a period without further calls. + * + * Typical use is coalescing rapid model or editor updates into one refresh: + * + * debouncer.call([this] { refresh(); }); + * + * The pending callback is owned by the debouncer and replaced when call() is invoked + * again. The debouncer must outlive any callback it schedules; parenting it to the + * owning widget is usually enough. + */ +class Debouncer : public QObject +{ +public: + explicit Debouncer(int intervalMs, QObject *parent = nullptr) : QObject(parent) + { + timer.setSingleShot(true); + timer.setInterval(intervalMs); + connect(&timer, &QTimer::timeout, this, [this] { + if (callback) { + auto pending = std::move(callback); + callback = nullptr; + pending(); + } + }); + } + + /** + * @brief Schedules @p newCallback, replacing any callback not run yet. + */ + void call(std::function newCallback) + { + callback = std::move(newCallback); + timer.start(); + } + + /** + * @brief Drops the pending callback and stops the timer. + */ + void cancel() + { + timer.stop(); + callback = nullptr; + } + +private: + QTimer timer; + std::function callback; +}; +} // namespace QtUtils + +#endif // COCKATRICE_DEBOUNCER_H From 77a3875c25e051ccfa3b1a320603307dcb45018d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:48:11 +0200 Subject: [PATCH 110/247] [VisualDatabase] Debounce filter refreshes with QtUtils::Debouncer (#7624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../visual_database_display_color_filter_widget.cpp | 3 +-- .../visual_database_display_color_filter_widget.h | 2 ++ ...isual_database_display_format_legality_filter_widget.cpp | 6 ++---- .../visual_database_display_format_legality_filter_widget.h | 2 ++ .../visual_database_display_main_type_filter_widget.cpp | 6 ++---- .../visual_database_display_main_type_filter_widget.h | 2 ++ .../visual_database_display_name_filter_widget.cpp | 2 +- .../visual_database_display_name_filter_widget.h | 2 ++ .../visual_database_display_set_filter_widget.cpp | 5 ++--- .../visual_database_display_set_filter_widget.h | 2 ++ .../visual_database_display_sub_type_filter_widget.cpp | 6 ++---- .../visual_database_display_sub_type_filter_widget.h | 2 ++ 12 files changed, 22 insertions(+), 18 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index cf815ace0..464d62e4a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -49,7 +48,7 @@ VisualDatabaseDisplayColorFilterWidget::VisualDatabaseDisplayColorFilterWidget(Q &VisualDatabaseDisplayColorFilterWidget::updateFilterMode); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); // Call retranslateUi to set the initial text retranslateUi(); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 38812b02a..184f1f96c 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -12,6 +12,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -71,6 +72,7 @@ private: QHBoxLayout *layout; QComboBox *modeComboBox; FilterMode currentMode = FilterMode::Includes; // Default mode + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_COLOR_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp index 3d6a6e56c..e0734fbb8 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -72,9 +71,8 @@ VisualDatabaseDisplayFormatLegalityFilterWidget::VisualDatabaseDisplayFormatLega layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayFormatLegalityFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplayFormatLegalityFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createFormatButtons(); // Populate buttons initially updateFilterMode(); // Initialize toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h index e94593e67..6cc55062e 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h @@ -4,6 +4,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -42,6 +43,7 @@ private: QMap formatButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_FORMAT_LEGALITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp index 1f63a6b01..018841b30 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -65,9 +64,8 @@ VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWi toggleButton = new QPushButton(this); layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayMainTypeFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplayMainTypeFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createMainTypeButtons(); // Populate buttons initially updateFilterMode(); // Initialize toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h index b5a6f332c..1e2fa2b39 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -48,6 +49,7 @@ private: QMap typeButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_MAIN_TYPE_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp index 3e4e8bcc0..e335cd4c0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp @@ -62,7 +62,7 @@ VisualDatabaseDisplayNameFilterWidget::VisualDatabaseDisplayNameFilterWidget(QWi &VisualDatabaseDisplayNameFilterWidget::actLoadFromClipboard); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayNameFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h index 1629cb93b..ba2684e05 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class DeckListModel; @@ -46,6 +47,7 @@ private: QPushButton *loadFromClipboardButton; QMap activeFilters; // Store active name filter buttons + QtUtils::Debouncer syncDebouncer{100}; private slots: void actLoadFromDeck(); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index d15600564..fe583361a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -101,7 +100,7 @@ VisualDatabaseDisplaySetFilterWidget::VisualDatabaseDisplaySetFilterWidget(QWidg layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplaySetFilterWidget::updateFilterMode); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplaySetFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createSetButtons(); // Populate buttons initially updateFilterMode(); @@ -304,5 +303,5 @@ void VisualDatabaseDisplaySetFilterWidget::updateFilterMode() // Reconnect the layoutChanged -> sync lambda connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplaySetFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h index 468e97e0d..4874df79b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -64,6 +65,7 @@ private: QMap activeSets; // Track active set filters bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_SET_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp index 26fdce64f..68a1edf11 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -70,9 +69,8 @@ VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidg toggleButton = new QPushButton(this); layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplaySubTypeFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplaySubTypeFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createSubTypeButtons(); // Populate buttons initially updateFilterMode(); // Initialize the toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h index 7929063fd..8bef870ed 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -50,6 +51,7 @@ private: QMap typeButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_SUB_TYPE_FILTER_WIDGET_H From 66b9338c90d33dde24273b6e0e904e492fb4e75c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:50:25 +0200 Subject: [PATCH 111/247] [QtUtils] Use makeScrollArea() for the remaining scroll areas (#7625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/interface/palette_editor/palette_grid_widget.cpp | 3 +-- .../widgets/deck_analytics/deck_analytics_widget.cpp | 4 ++-- .../widgets/onboarding/pages/preferences_setup_page.cpp | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index dc61a0099..463c4038d 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -66,8 +66,7 @@ static const QMap APP_ROLE_DESCRIPTIONS = { PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) { - scroll = new QScrollArea(this); - scroll->setWidgetResizable(true); + scroll = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scroll->setFrameShape(QFrame::NoFrame); gridHost = new QWidget; diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp index 9f6257930..e0ad7af43 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnalyzer *_statsAnalyzer) @@ -54,8 +55,7 @@ DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnal connect(includeSideboardCheckBox, &QCheckBox::clicked, this, &DeckAnalyticsWidget::includeSideboardChanged); // Scroll area and container - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scrollArea->setFrameShape(QFrame::NoFrame); panelContainer = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp index da08e3a12..486ada155 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -20,6 +20,7 @@ #include #include #include +#include namespace { @@ -68,9 +69,8 @@ PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage contentLayout->addStretch(); - auto *scrollArea = new QScrollArea(this); + auto *scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scrollArea->setWidget(content); - scrollArea->setWidgetResizable(true); scrollArea->setFrameShape(QFrame::NoFrame); auto *layout = new QVBoxLayout(this); From 3b07b10b1905e0dfad68864aa79be9e7da9a6b2c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:11:09 +0200 Subject: [PATCH 112/247] [Avatar] Respect EXIF orientation and decode at target size (#7634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the avatar with setAutoTransform(true) so camera orientation is applied, and ask QImageReader for a scaled decode at MAX_AVATAR_DIMENSION instead of decoding a full-resolution photo and scaling it afterwards. Verification: cockatrice built. Co-authored-by: Lukas Brübach --- .../interface/widgets/dialogs/dlg_edit_avatar.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp index 520ece007..7f0141bd5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp @@ -71,7 +71,21 @@ void DlgEditAvatar::actBrowse() QImageReader imgReader; imgReader.setDecideFormatFromContent(true); + // Honour the EXIF orientation phone cameras write; otherwise portrait + // photos are shown and stored sideways. + imgReader.setAutoTransform(true); imgReader.setFileName(fileName); + + // Decode straight to the maximum avatar size. Scaling afterwards decodes + // the full-resolution image first, which can be tens of megabytes for a + // modern camera photo. + const QSize sourceSize = imgReader.size(); + if (sourceSize.isValid() && + (sourceSize.width() > MAX_AVATAR_DIMENSION || sourceSize.height() > MAX_AVATAR_DIMENSION)) { + const QSize targetSize = sourceSize.scaled(MAX_AVATAR_DIMENSION, MAX_AVATAR_DIMENSION, Qt::KeepAspectRatio); + imgReader.setScaledSize(targetSize); + } + if (!imgReader.read(&image)) { qCWarning(DlgEditAvatarLog) << "Avatar image loading failed for file:" << fileName; imageLabel->setText(tr("Invalid image chosen.")); From 518c7e61259febd7c4af130405e69a9dbf6a5eb2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:33:43 +0200 Subject: [PATCH 113/247] [Network] Add a configured QNetworkAccessManager factory (#7628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Network] Add a configured QNetworkAccessManager factory Every HTTP caller built its own QNetworkAccessManager, so the system proxy and a transfer timeout were easy to omit. Add NetworkManagerFactory::create() as the single place that applies the process-wide proxy configuration and a transfer timeout, and use it at all 14 construction sites. Per-site settings such as disk caches and manual redirect policies stay with their callers. * [Tests] Link libcockatrice_network into the tests that compile its headers release_channel.cpp and card_picture_loader_worker.cpp now include libcockatrice/network/network_manager.h, but the test targets that compile them only linked libcockatrice_settings, so the include directory never reached them and every platform failed with "file not found": loader_local_matching_test and picture_loader_benchmark_test. libcockatrice_network is an INTERFACE library, so linking it costs these targets nothing but hands over the include path. --------- Co-authored-by: Lukas Brübach --- .../interfaces/deck_stats_interface.cpp | 3 +- .../interfaces/tapped_out_interface.cpp | 3 +- .../spoiler_background_updater.cpp | 3 +- .../network/update/client/release_channel.cpp | 3 +- .../update/client/update_downloader.cpp | 3 +- .../card_picture_loader_worker.cpp | 4 +- .../dialogs/dlg_load_deck_from_website.cpp | 3 +- .../widgets/server/handle_public_servers.cpp | 3 +- ..._response_deck_listings_display_widget.cpp | 4 +- .../tabs/api/archidekt/tab_archidekt.cpp | 4 +- .../handle_commander_brackets.cpp | 3 +- .../widgets/tabs/api/edhrec/tab_edhrec.cpp | 4 +- .../tabs/api/edhrec/tab_edhrec_main.cpp | 4 +- .../libcockatrice/network/network_manager.h | 38 +++++++++++++++++++ oracle/src/oraclewizard.cpp | 3 +- tests/CMakeLists.txt | 1 + tests/picture_loader_benchmark/CMakeLists.txt | 3 +- 17 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 libcockatrice_network/libcockatrice/network/network_manager.h diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index d5cfdbbc9..508f0bdc9 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -20,11 +20,12 @@ #include #include #include +#include #include DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent) { - manager = new QNetworkAccessManager(this); + manager = NetworkManagerFactory::create(this); connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished); } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 30044c2b6..34dd03765 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -26,11 +26,12 @@ #include #include #include +#include #include TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent) { - manager = new QNetworkAccessManager(this); + manager = NetworkManagerFactory::create(this); connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished); } diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 192029b23..62668c4d9 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -56,7 +57,7 @@ void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool sav void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults) { - auto *nam = new QNetworkAccessManager(this); + auto *nam = NetworkManagerFactory::create(this); auto request = QNetworkRequest(url); request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); QNetworkReply *reply = nam->get(request); diff --git a/cockatrice/src/client/network/update/client/release_channel.cpp b/cockatrice/src/client/network/update/client/release_channel.cpp index 45c503623..ce80a2a76 100644 --- a/cockatrice/src/client/network/update/client/release_channel.cpp +++ b/cockatrice/src/client/network/update/client/release_channel.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -62,7 +63,7 @@ std::optional getProductVersionMajor() } // namespace -ReleaseChannel::ReleaseChannel() : netMan(new QNetworkAccessManager(this)), response(nullptr), lastRelease(nullptr) +ReleaseChannel::ReleaseChannel() : netMan(NetworkManagerFactory::create(this)), response(nullptr), lastRelease(nullptr) { } diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index c86972d60..8868876fa 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -9,10 +9,11 @@ #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { - netMan = new QNetworkAccessManager(this); + netMan = NetworkManagerFactory::create(this); } void UpdateDownloader::beginDownload(QUrl downloadUrl) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 150b4c544..3ab5eb684 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -46,11 +47,10 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits()) { - networkManager = new QNetworkAccessManager(this); + networkManager = NetworkManagerFactory::create(this); // We need a timeout to ensure requests don't hang indefinitely in case of // cache corruption, see related Qt bug: https://bugreports.qt.io/browse/QTBUG-111397 // Use Qt's default timeout (30s, as of 2023-02-22) - networkManager->setTransferTimeout(); cache = new QNetworkDiskCache(this); cache->setCacheDirectory(SettingsCache::instance().getNetworkCachePath()); cache->setMaximumCacheSize(1024L * 1024L * diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp index 88b7e2bb3..1c1ada302 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -34,7 +35,7 @@ class QWidget; DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) { - nam = new QNetworkAccessManager(this); + nam = NetworkManagerFactory::create(this); layout = new QVBoxLayout(this); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp index 08b7837dd..1c0cbf962 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp @@ -13,13 +13,14 @@ #include #include #include +#include #include #include #define PUBLIC_SERVERS_JSON "https://cockatrice.github.io/public-servers.json" HandlePublicServers::HandlePublicServers(QObject *parent) - : QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr) + : QObject(parent), nam(NetworkManagerFactory::create(this)), reply(nullptr) { } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp index 81619bdcc..f8bf3c0dc 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -27,8 +28,7 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD cardSizeSlider->enableCtrlScrollResize(flowWidget); - imageNetworkManager = new QNetworkAccessManager(this); - imageNetworkManager->setTransferTimeout(); // Use Qt's default timeout + imageNetworkManager = NetworkManagerFactory::create(this); imageNetworkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); // Add widgets for deck listings diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 60bf00c0a..d157f75cb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -48,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -58,8 +59,7 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor), currentPage(1), isLoadingMore(false), isListMode(true) { // Initialize network - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp index 22200ffa2..407f53e99 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -12,12 +12,13 @@ #include #include #include +#include #include static const QUrl COMMANDER_BRACKET_JSON_URL(QStringLiteral("https://cockatrice.github.io/commander-brackets.json")); HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) - : QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr) + : QObject(parent), nam(NetworkManagerFactory::create(this)), reply(nullptr) { } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp index 3aa395126..9dd0cca03 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp @@ -18,14 +18,14 @@ #include #include #include +#include #include class TabSupervisor; TabEdhRec::TabEdhRec(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) { - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); // Use Qt's default timeout + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, &QNetworkAccessManager::finished, this, &TabEdhRec::processApiJson); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp index ca575dc02..741a1e85f 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 @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -44,8 +45,7 @@ static bool canBeCommander(const CardInfoPtr &cardInfo) TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) { - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); // Use Qt's default timeout + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, SIGNAL(finished(QNetworkReply *)), this, SLOT(processApiJson(QNetworkReply *))); diff --git a/libcockatrice_network/libcockatrice/network/network_manager.h b/libcockatrice_network/libcockatrice/network/network_manager.h new file mode 100644 index 000000000..bb20e1315 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/network_manager.h @@ -0,0 +1,38 @@ +#ifndef COCKATRICE_NETWORK_NETWORK_MANAGER_H +#define COCKATRICE_NETWORK_NETWORK_MANAGER_H + +#include +#include +#include + +/** + * @file network_manager.h + * @ingroup Network + * @brief Creates QNetworkAccessManagers with the application-wide baseline. + * + * Every manager in the client should start from NetworkManagerFactory::create() + * so features that apply to all HTTP traffic (the system proxy, a transfer + * timeout) cannot be forgotten in one of the many call sites. Feature-specific + * setup such as a disk cache or a manual redirect policy stays with the caller. + */ +namespace NetworkManagerFactory +{ +/** + * @brief Returns a QNetworkAccessManager configured for Cockatrice. + * + * The manager is parented to @p parent. The system proxy is enabled once, + * process-wide, the first time this function is called. Transfers use Qt's + * default timeout instead of waiting forever. + */ +inline QNetworkAccessManager *create(QObject *parent = nullptr) +{ + static std::once_flag systemProxyFlag; + std::call_once(systemProxyFlag, [] { QNetworkProxyFactory::setUseSystemConfiguration(true); }); + + auto *manager = new QNetworkAccessManager(parent); + manager->setTransferTimeout(); + return manager; +} +} // namespace NetworkManagerFactory + +#endif // COCKATRICE_NETWORK_NETWORK_MANAGER_H diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index d2b3a71a4..47afecc06 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -53,7 +54,7 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) // foreignData for any other language is never imported. importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); - nam = new QNetworkAccessManager(this); + nam = NetworkManagerFactory::create(this); QList pages; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5420c07f5..e2c3252ee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -369,6 +369,7 @@ cockatrice_add_test( ${VERSION_STRING_CPP} loader_local_matching_test.cpp LIBS + libcockatrice_network libcockatrice_settings INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src diff --git a/tests/picture_loader_benchmark/CMakeLists.txt b/tests/picture_loader_benchmark/CMakeLists.txt index 0684f58f4..9b8d15999 100644 --- a/tests/picture_loader_benchmark/CMakeLists.txt +++ b/tests/picture_loader_benchmark/CMakeLists.txt @@ -15,8 +15,9 @@ cockatrice_add_manual_test( settings_cache_mock.cpp LIBS libcockatrice_card - libcockatrice_settings libcockatrice_interfaces + libcockatrice_network + libcockatrice_settings libcockatrice_utility INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src From 5ddfcd7ead665907e40e1e62ddba308813023868 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:36:14 +0200 Subject: [PATCH 114/247] [Pictures] Watch the custom picture folder instead of polling (#7635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local picture loader walked the whole custom picture tree every ten seconds on the GUI thread. Watch the root and every subdirectory with QFileSystemWatcher, debounce bursts of events into a single 500 ms refresh, and keep a five-minute fallback poll for filesystems where watching fails. Add an explicit-path constructor and indexedPaths() so the indexing can be tested without SettingsCache, and emit indexChanged() after a rebuild. Verification: cockatrice built. Co-authored-by: Lukas Brübach --- .../card_picture_loader_local.cpp | 75 ++++++++++++++++--- .../card_picture_loader_local.h | 30 +++++++- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp index 1437e23f4..fde6830c2 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -19,35 +20,62 @@ #include #include -static constexpr int REFRESH_INTERVAL_MS = 10 * 1000; +static constexpr int REFRESH_DEBOUNCE_MS = 500; +// Watching can fail on network and FUSE mounts; the slow poll still picks those +// changes up without the old ten-second full rescan. +static constexpr int FALLBACK_REFRESH_INTERVAL_MS = 5 * 60 * 1000; CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent) - : QObject(parent), picsPath(SettingsCache::instance().paths().getPicsPath()), - customPicsPath(SettingsCache::instance().paths().getCustomPicsPath()) + : CardPictureLoaderLocal(parent, + SettingsCache::instance().paths().getPicsPath(), + SettingsCache::instance().paths().getCustomPicsPath()) { // Hook up signals to settings connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this, &CardPictureLoaderLocal::picsPathChanged); +} + +CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent, + const QString &_picsPath, + const QString &_customPicsPath) + : QObject(parent), picsPath(_picsPath), customPicsPath(_customPicsPath), + folderWatcher(new QFileSystemWatcher(this)), indexRefreshTimer(new QTimer(this)), + fallbackRefreshTimer(new QTimer(this)) +{ + indexRefreshTimer->setSingleShot(true); + indexRefreshTimer->setInterval(REFRESH_DEBOUNCE_MS); + connect(indexRefreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); + + connect(folderWatcher, &QFileSystemWatcher::directoryChanged, indexRefreshTimer, qOverload<>(&QTimer::start)); + connect(folderWatcher, &QFileSystemWatcher::fileChanged, indexRefreshTimer, qOverload<>(&QTimer::start)); + + fallbackRefreshTimer->setInterval(FALLBACK_REFRESH_INTERVAL_MS); + connect(fallbackRefreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); + fallbackRefreshTimer->start(); refreshIndex(); - - refreshTimer = new QTimer(this); - connect(refreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); - refreshTimer->start(REFRESH_INTERVAL_MS); } void CardPictureLoaderLocal::refreshIndex() { customFolderIndex.clear(); + QSet directories; + + QDir customDir(customPicsPath); + if (customDir.exists()) { + directories.insert(customDir.absolutePath()); + } QDirIterator it(customPicsPath, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); // Recursively check all subdirectories of the CUSTOM folder while (it.hasNext()) { - QString thisPath(it.next()); - QFileInfo thisFileInfo(thisPath); + const QString thisPath = it.next(); + const QFileInfo thisFileInfo = it.fileInfo(); - if (thisFileInfo.isFile()) { + if (thisFileInfo.isDir()) { + directories.insert(thisFileInfo.absoluteFilePath()); + } else if (thisFileInfo.isFile()) { // We don't know which name is the correctedName because there might be '.'s in the cardName. // Just add all possibilities to be sure. customFolderIndex.insert(thisFileInfo.baseName(), thisFileInfo.absoluteFilePath()); @@ -55,8 +83,34 @@ void CardPictureLoaderLocal::refreshIndex() } } + updateWatchedDirectories(directories); + qCDebug(CardPictureLoaderLocalLog) << "Finished indexing local image folder CUSTOM; map now has" << customFolderIndex.size() << "entries."; + emit indexChanged(); +} + +void CardPictureLoaderLocal::updateWatchedDirectories(const QSet &directories) +{ + const QSet stale = watchedDirectories - directories; + if (!stale.isEmpty()) { + folderWatcher->removePaths(QStringList(stale.begin(), stale.end())); + } + + const QSet added = directories - watchedDirectories; + if (!added.isEmpty()) { + const QStringList failed = folderWatcher->addPaths(QStringList(added.begin(), added.end())); + if (!failed.isEmpty()) { + qCDebug(CardPictureLoaderLocalLog) << "Could not watch custom picture directories:" << failed; + } + } + + watchedDirectories = directories; +} + +QStringList CardPictureLoaderLocal::indexedPaths(const QString &baseName) const +{ + return customFolderIndex.values(baseName); } QImage CardPictureLoaderLocal::tryLoad(const ExactCard &toLoad) const @@ -145,4 +199,5 @@ void CardPictureLoaderLocal::picsPathChanged() { picsPath = SettingsCache::instance().paths().getPicsPath(); customPicsPath = SettingsCache::instance().paths().getCustomPicsPath(); + refreshIndex(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h index 009897acc..35190366d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h @@ -5,10 +5,12 @@ #include #include #include +#include #include #include class ExactCard; +class QFileSystemWatcher; class QTimer; inline Q_LOGGING_CATEGORY(CardPictureLoaderLocalLog, "card_picture_loader.local"); @@ -40,6 +42,18 @@ public: */ explicit CardPictureLoaderLocal(QObject *parent); + /** + * @brief Constructs the loader against explicit folders without SettingsCache. + * + * Used by tests and by callers that already resolved the configured paths. + */ + CardPictureLoaderLocal(QObject *parent, const QString &picsPath, const QString &customPicsPath); + + /** + * @brief Returns every indexed file whose base name matches @p baseName. + */ + [[nodiscard]] QStringList indexedPaths(const QString &baseName) const; + /** * @brief Attempts to load a card image from local disk or custom folders. * @param toLoad ExactCard object representing the card to load. @@ -54,7 +68,10 @@ private: QString customPicsPath; ///< Path to custom card image folder QMultiHash customFolderIndex; ///< Multimap from cardName to file paths in CUSTOM folder - QTimer *refreshTimer; ///< Timer for periodic folder index refresh + QFileSystemWatcher *folderWatcher; ///< Watches the custom folder tree for changes + QTimer *indexRefreshTimer; ///< Debounces bursts of filesystem events + QTimer *fallbackRefreshTimer; ///< Slow poll for filesystems without change notification + QSet watchedDirectories; ///< Directories currently registered with folderWatcher /** * @brief Rebuilds the index of the custom image folder. @@ -65,6 +82,11 @@ private: */ void refreshIndex(); + /** + * @brief Keeps the watcher registered on exactly @p directories. + */ + void updateWatchedDirectories(const QSet &directories); + /** * @brief Attempts to load a card image from disk given its set and name info. * @param setName Corrected short name of the card's set. @@ -82,6 +104,12 @@ private: const QString &collectorNumber, const QString &providerId) const; +signals: + /** + * @brief Emitted after the custom folder index has been rebuilt. + */ + void indexChanged(); + private slots: /** * @brief Updates internal paths when the user changes picture settings. From 5fbc636f9361d80f94d3f7391745fe5173a2c906 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:01:04 +0200 Subject: [PATCH 115/247] [Models] Use QCollator for natural, cached sorting (#7633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Models] Use QCollator for natural, cached sorting QString::localeAwareCompare rebuilds its collation data for every comparison, and it compares numbers as text. Keep one QCollator per model, enable numeric mode, and use it for the card name, set and user name sorts. Add a test that sorts "Forest 1", "Forest 2", "Forest 10" through DeckListSortFilterProxyModel and checks the natural order. * [Models] Sort naturally under the C locale too Numeric mode only takes effect through a real locale: under the C locale, which headless CI processes run in, QCollator silently falls back to string order and sorts "Forest 10" before "Forest 2". That made collator_sort_test fail on every Linux leg while passing locally. Add makeNaturalSortCollator(), which enables numeric mode and pins English only when the process locale is the degenerate one, and build the models' collators through it. Users with a real locale keep their own collation. * [Models] Fix compile of natural sorting (QStringView mismatch) * nit: initialize collators in member initializer lists * nit: namespace makeNaturalSortCollator under NaturalSort --------- Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_list_widget.cpp | 12 ++++++-- .../database/card_database_display_model.cpp | 12 ++++---- .../database/card_database_display_model.h | 6 +++- .../database/card_set/card_sets_model.cpp | 6 ++-- .../database/card_set/card_sets_model.h | 4 +++ .../deck_list_sort_filter_proxy_model.cpp | 8 ++--- .../deck_list_sort_filter_proxy_model.h | 7 ++++- .../models/natural_sort_collator.h | 30 +++++++++++++++++++ tests/deck_list_model/CMakeLists.txt | 12 ++++++++ tests/deck_list_model/collator_sort_test.cpp | 30 +++++++++++++++++++ 10 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 libcockatrice_models/libcockatrice/models/natural_sort_collator.h create mode 100644 tests/deck_list_model/collator_sort_test.cpp diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index efbdcfb23..a20b39b82 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,7 @@ #include #include #include +#include #include #include #include @@ -272,6 +274,11 @@ void UserListTWI::setOnline(bool online) */ bool UserListTWI::operator<(const QTreeWidgetItem &other) const { + // QCollator caches the locale data, unlike QString::localeAwareCompare, + // which rebuilds it for every pair. The tree sorts one list at a time, so a + // single shared, immutable collator is enough. + static const QCollator collator = NaturalSort::makeNaturalSortCollator(); + // Sort by online/offline if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) { return data(0, UserListRoles::Online).toBool(); @@ -287,8 +294,7 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { - return QString::localeAwareCompare(data(2, Qt::UserRole).toString(), - other.data(2, Qt::UserRole).toString()) < 0; + return collator.compare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; } else if (lhsUserLevelFlags.testFlag(userLevelEntry) != rhsUserLevelFlags.testFlag(userLevelEntry)) { return lhsUserLevelFlags.testFlag(userLevelEntry) > rhsUserLevelFlags.testFlag(userLevelEntry); } @@ -313,7 +319,7 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const } // Sort by name - return QString::localeAwareCompare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; + return collator.compare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; } UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index c333f5506..5573deb18 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -1,5 +1,6 @@ #include "card_database_display_model.h" +#include "../natural_sort_collator.h" #include "card_database_model.h" #include "libcockatrice/card/card_localization.h" #include "libcockatrice/filters/filter_string.h" @@ -20,7 +21,8 @@ class QObject; CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) - : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr) + : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), + collator(NaturalSort::makeNaturalSortCollator()) { filterTree = nullptr; setFilterCaseSensitivity(Qt::CaseInsensitive); @@ -120,10 +122,10 @@ bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelInd return lessThanNumerically(leftPT.mid(leftSlash + 1), rightPT.mid(rightSlash + 1)) < 0; } } - return QString::localeAwareCompare(leftString, rightString) < 0; + return collator.compare(leftString, rightString) < 0; } -int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) +int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) const { if (left == right) { return 0; @@ -182,7 +184,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView } else { // both parsed, same number, but at least one has something else // so compare the part after the number - prefer nothing - return QString::localeAwareCompare(leftAfterNum, rightAfterNum); + return collator.compare(leftAfterNum, rightAfterNum); } } else if (okLeft) { return -1; @@ -190,7 +192,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView return 1; } // couldn't parse it, just return String comparison - return QString::localeAwareCompare(left, right); + return collator.compare(left, right); } bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex & /*sourceParent*/) const { diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index 77152a390..343a7482e 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -10,6 +10,7 @@ #include "libcockatrice/card/card_info.h" +#include #include #include #include @@ -49,6 +50,9 @@ private: /** The translation table that will be used for sanitizeCardName. */ static QMap characterTranslation; + /** Natural, locale-aware comparison shared by every sort in this model. */ + QCollator collator; + public: explicit CardDatabaseDisplayModel(QObject *parent = nullptr); void setSourceModel(QAbstractItemModel *model) override; @@ -89,7 +93,7 @@ signals: protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; - static int lessThanNumerically(QStringView left, QStringView right); + int lessThanNumerically(QStringView left, QStringView right) const; [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; [[nodiscard]] bool rowMatchesCardName(CardInfoPtr info) const; diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp index a0b9c1061..b8bdc3466 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp @@ -1,5 +1,6 @@ #include "card_sets_model.h" +#include "../../natural_sort_collator.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/set/card_set.h" #include "libcockatrice/interfaces/interface_card_set_priority_controller.h" @@ -296,7 +297,8 @@ QStringList SetsModel::mimeTypes() const return QStringList() << "application/x-cockatricecardset"; } -SetsDisplayModel::SetsDisplayModel(QObject *parent) : QSortFilterProxyModel(parent) +SetsDisplayModel::SetsDisplayModel(QObject *parent) + : QSortFilterProxyModel(parent), collator(NaturalSort::makeNaturalSortCollator()) { setFilterCaseSensitivity(Qt::CaseInsensitive); setSortCaseSensitivity(Qt::CaseInsensitive); @@ -331,5 +333,5 @@ bool SetsDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &righ QString leftString = sourceModel()->data(left, SetsModel::SortRole).toString(); QString rightString = sourceModel()->data(right, SetsModel::SortRole).toString(); - return QString::localeAwareCompare(leftString, rightString) < 0; + return collator.compare(leftString, rightString) < 0; } diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h index 354414dc8..17eab6c53 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h @@ -11,6 +11,7 @@ #include "libcockatrice/card/set/card_set_list.h" #include +#include #include #include #include @@ -112,6 +113,9 @@ protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; void fetchMore(const QModelIndex &index) override; + +private: + QCollator collator; }; #endif diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp index dcb294ec8..cf3410560 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp @@ -23,7 +23,7 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode if (!leftIsCard || !rightIsCard) { QString lName = src->data(left.siblingAtColumn(DeckListModelColumns::CARD_NAME), Qt::EditRole).toString(); QString rName = src->data(right.siblingAtColumn(DeckListModelColumns::CARD_NAME), Qt::EditRole).toString(); - return lName.localeAwareCompare(rName) < 0; + return collator.compare(lName, rName) < 0; } // Both are cards -> apply sort criteria @@ -38,7 +38,7 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode if (crit == "name") { QString ln = lNode->getName(); QString rn = rNode->getName(); - int cmp = ln.localeAwareCompare(rn); + int cmp = collator.compare(ln, rn); if (cmp != 0) { return cmp < 0; } @@ -51,14 +51,14 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode } else if (crit == "colors") { QString lr = lInfo ? lInfo->getColors() : QString(); QString rr = rInfo ? rInfo->getColors() : QString(); - int cmp = lr.localeAwareCompare(rr); + int cmp = collator.compare(lr, rr); if (cmp != 0) { return cmp < 0; } } else if (crit == "maintype") { QString lr = lInfo ? lInfo->getMainCardType() : QString(); QString rr = rInfo ? rInfo->getMainCardType() : QString(); - int cmp = lr.localeAwareCompare(rr); + int cmp = collator.compare(lr, rr); if (cmp != 0) { return cmp < 0; } diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h index 522ed1e23..b911b4740 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h @@ -7,6 +7,9 @@ #ifndef COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H #define COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H +#include "../natural_sort_collator.h" + +#include #include #include #include @@ -19,7 +22,8 @@ class DeckListSortFilterProxyModel : public QSortFilterProxyModel { Q_OBJECT public: - explicit DeckListSortFilterProxyModel(QObject *parent = nullptr) : QSortFilterProxyModel(parent) + explicit DeckListSortFilterProxyModel(QObject *parent = nullptr) + : QSortFilterProxyModel(parent), collator(NaturalSort::makeNaturalSortCollator()) { } @@ -34,6 +38,7 @@ protected: private: QStringList sortCriteria; + QCollator collator; }; #endif // COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H diff --git a/libcockatrice_models/libcockatrice/models/natural_sort_collator.h b/libcockatrice_models/libcockatrice/models/natural_sort_collator.h new file mode 100644 index 000000000..8c026ca06 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/natural_sort_collator.h @@ -0,0 +1,30 @@ +#ifndef NATURAL_SORT_COLLATOR_H +#define NATURAL_SORT_COLLATOR_H + +#include +#include + +namespace NaturalSort +{ + +/** + * @brief A collator that compares runs of digits numerically, whatever the process locale is. + * + * Numeric mode only takes effect through a real locale. Under the C locale, which + * headless CI processes run in, QCollator silently falls back to plain string order + * and sorts "Forest 10" before "Forest 2". Pin English for that single degenerate + * locale so sorting is the same everywhere, and keep the user's locale otherwise. + */ +inline QCollator makeNaturalSortCollator() +{ + QCollator collator; + collator.setNumericMode(true); + if (QLocale().language() == QLocale::C) { + collator.setLocale(QLocale(QLocale::English)); + } + return collator; +} + +} // namespace NaturalSort + +#endif // NATURAL_SORT_COLLATOR_H \ No newline at end of file diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt index cd7d693f3..82a1965d2 100644 --- a/tests/deck_list_model/CMakeLists.txt +++ b/tests/deck_list_model/CMakeLists.txt @@ -21,3 +21,15 @@ cockatrice_add_test( libcockatrice_card libcockatrice_deck_list ) + +cockatrice_add_test( + NAME + collator_sort_test + SOURCES + ${VERSION_STRING_CPP} + collator_sort_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list +) diff --git a/tests/deck_list_model/collator_sort_test.cpp b/tests/deck_list_model/collator_sort_test.cpp new file mode 100644 index 000000000..b4ed85525 --- /dev/null +++ b/tests/deck_list_model/collator_sort_test.cpp @@ -0,0 +1,30 @@ +#include +#include +#include +#include +#include +#include + +namespace +{ +TEST(CollatorSort, NumericNamesSortNaturally) +{ + QStandardItemModel source(0, 2); + const QStringList names = {"Forest 10", "Forest 2", "Forest 1"}; + for (int row = 0; row < names.size(); ++row) { + source.setItem(row, DeckListModelColumns::CARD_NAME, new QStandardItem(names.at(row))); + } + + DeckListSortFilterProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setSortCriteria({"name"}); + proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); + + QStringList actual; + for (int row = 0; row < proxy.rowCount(); ++row) { + actual.append(proxy.index(row, DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString()); + } + + EXPECT_EQ(actual, (QStringList{"Forest 1", "Forest 2", "Forest 10"})); +} +} // namespace From f33cfcb288c65e34848d799897e4b19323703b66 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:34:56 +0100 Subject: [PATCH 116/247] [UI] Show 'Password saved' placeholder when verifier is stored (#7649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 43b40bf30..1f567989e 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -295,9 +295,15 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) const QString stored = _data.at(4); if (stored.startsWith("$")) { storedVerifier = stored; + passwordEdit->clear(); + passwordEdit->setPlaceholderText(tr("Password saved")); } else { passwordEdit->setText(stored); + passwordEdit->setPlaceholderText(QString()); } + } else { + passwordEdit->clear(); + passwordEdit->setPlaceholderText(QString()); } if (!_data.at(6).isEmpty()) { @@ -323,6 +329,7 @@ void DlgConnect::newHostSelected(bool state) portEdit->setDisabled(false); playernameEdit->clear(); passwordEdit->clear(); + passwordEdit->setPlaceholderText(QString()); storedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); From a4c4c676744b8676598a65d8468904b42e4e776e Mon Sep 17 00:00:00 2001 From: Neel Jogani Date: Sun, 25 Oct 2026 17:00:17 +0530 Subject: [PATCH 117/247] Fix tab retranslation (#7580) * Retranslate all open tabs on language change * Address review: deeper Visual DB retranslation, index-based Archidekt size logic In Qt 6, setItemText() on the current item emits currentTextChanged but not currentIndexChanged, so connecting the Archidekt combos to currentTextChanged fired a search on every language change. Both combos now use currentIndexChanged, and buildSearchUrl() compares the size-logic combo by index so translated labels cannot drop sizeLogic. * Derive display mode button text in one place --- .../tabs/api/archidekt/tab_archidekt.cpp | 37 +++++++++----- .../tabs/api/archidekt/tab_archidekt.h | 4 +- .../interface/widgets/tabs/tab_supervisor.cpp | 48 ++----------------- .../tabs/tab_visual_database_display.cpp | 3 +- .../visual_database_display_widget.cpp | 14 ++++-- .../visual_database_display_widget.h | 1 + 6 files changed, 45 insertions(+), 62 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index d157f75cb..7f7a2d3fd 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -220,9 +220,9 @@ void TabArchidekt::setupFilterWidgets() // Advanced filters (in secondary toolbar) // EDH Bracket - auto *bracketLabel = new QLabel(tr("Bracket:"), secondaryToolbar); + bracketLabel = new QLabel(secondaryToolbar); edhBracketCombo = new QComboBox(secondaryToolbar); - edhBracketCombo->addItem(tr("Any")); + edhBracketCombo->addItem(QString()); edhBracketCombo->addItems({"1", "2", "3", "4", "5"}); edhBracketCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); @@ -276,7 +276,6 @@ void TabArchidekt::setupFilterWidgets() sizeLayout->setContentsMargins(4, 4, 4, 4); minDeckSizeSpin = new QSpinBox(sizeContainer); - minDeckSizeSpin->setSpecialValueText(tr("Any")); minDeckSizeSpin->setRange(0, 200); minDeckSizeSpin->setValue(0); @@ -284,7 +283,8 @@ void TabArchidekt::setupFilterWidgets() minDeckSizeLogicCombo->addItems({"Exact", "≥", "≤"}); minDeckSizeLogicCombo->setCurrentIndex(1); - sizeLayout->addWidget(new QLabel(tr("Cards:"), sizeContainer)); + cardsLabel = new QLabel(sizeContainer); + sizeLayout->addWidget(cardsLabel); sizeLayout->addWidget(minDeckSizeSpin); sizeLayout->addWidget(minDeckSizeLogicCombo); @@ -341,9 +341,9 @@ void TabArchidekt::connectSignals() }); // Format filters trigger search - connect(edhBracketCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); + connect(edhBracketCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); connect(minDeckSizeSpin, qOverload(&QSpinBox::valueChanged), this, &TabArchidekt::doSearch); - connect(minDeckSizeLogicCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); + connect(minDeckSizeLogicCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); // Allow Enter key in text fields to trigger search connect(nameField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); @@ -393,8 +393,15 @@ void TabArchidekt::retranslateUi() commandersField->setPlaceholderText(tr("Commander...")); deckTagNameField->setPlaceholderText(tr("Tag...")); + bracketLabel->setText(tr("Bracket:")); + // Index 0 is the "Any" placeholder added in setupFilterWidgets() + edhBracketCombo->setItemText(0, tr("Any")); + formatButton->setButtonText(tr("Formats")); deckSizeButton->setButtonText(tr("Deck Size")); + cardsLabel->setText(tr("Cards:")); + minDeckSizeSpin->setSpecialValueText(tr("Any")); + minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); @@ -484,13 +491,17 @@ QString TabArchidekt::buildSearchUrl() query.addQueryItem("size", QString::number(minDeckSizeSpin->value())); QString logic = "GTE"; - QString selected = minDeckSizeLogicCombo->currentText(); - if (selected == "≥") { - logic = "GTE"; - } else if (selected == "≤") { - logic = "LTE"; - } else { - logic = ""; + // Index order matches setupFilterWidgets(): 0 = Exact, 1 = ≥, 2 = ≤ + switch (minDeckSizeLogicCombo->currentIndex()) { + case 1: + logic = "GTE"; + break; + case 2: + logic = "LTE"; + break; + default: + logic = ""; + break; } if (!logic.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index 015b8590b..c74380b63 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -287,9 +287,11 @@ private: SettingsButtonWidget *formatButton; ///< Collapsible button for format filters QVector formatChecks; ///< Individual checkboxes for each format + QLabel *bracketLabel; ///< Label for EDH bracket selection QComboBox *edhBracketCombo; ///< Dropdown for EDH bracket selection SettingsButtonWidget *deckSizeButton; ///< Collapsible button for deck size filter + QLabel *cardsLabel; ///< Label for deck size selection QSpinBox *minDeckSizeSpin; ///< Spinner to select minimum deck size QComboBox *minDeckSizeLogicCombo; ///< Combo box for size comparison logic @@ -300,4 +302,4 @@ private: CardInfoPtr cardToQuery; ///< Optional pre-selected card for initial filtering }; -#endif // COCKATRICE_TAB_ARCHIDEKT_H \ No newline at end of file +#endif // COCKATRICE_TAB_ARCHIDEKT_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 8ec76aaf0..c614fe7f6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -51,8 +51,6 @@ #include #include #include -#include -#include #include #include #include @@ -292,49 +290,11 @@ void TabSupervisor::retranslateUi() aTabDeveloper->setText(tr("Developer")); // tabs - QList tabs; - tabs.append(tabHome); - tabs.append(tabServer); - tabs.append(tabReplays); - tabs.append(tabDeckStorage); - tabs.append(tabAdmin); - tabs.append(tabAccount); - tabs.append(tabLog); - tabs.append(tabReport); - tabs.append(tabModeration); - tabs.append(tabCardArtRules); - tabs.append(tabDeveloper); - QMapIterator roomIterator(roomTabs); - while (roomIterator.hasNext()) { - tabs.append(roomIterator.next().value()); - } - QMapIterator gameIterator(gameTabs); - while (gameIterator.hasNext()) { - tabs.append(gameIterator.next().value()); - } - QMapIterator publicDecksIterator(publicDecksTabs); - while (publicDecksIterator.hasNext()) { - tabs.append(publicDecksIterator.next().value()); - } - QListIterator replayIterator(replayTabs); - while (replayIterator.hasNext()) { - tabs.append(replayIterator.next()); - } - QListIterator deckEditorIterator(deckEditorTabs); - while (deckEditorIterator.hasNext()) { - tabs.append(deckEditorIterator.next()); - } - QMapIterator messageIterator(messageTabs); - while (messageIterator.hasNext()) { - tabs.append(messageIterator.next().value()); - } - - for (auto &tab : tabs) { - if (tab) { - int idx = indexOf(tab); + for (int i = 0; i < count(); ++i) { + if (auto *tab = qobject_cast(widget(i))) { QString tabText = tab->getTabText(); - setTabText(idx, sanitizeTabName(tabText)); - setTabToolTip(idx, sanitizeHtml(tabText)); + setTabText(i, sanitizeTabName(tabText)); + setTabToolTip(i, sanitizeHtml(tabText)); tab->retranslateUi(); } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp index f4b007072..26d29463b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp @@ -23,9 +23,10 @@ TabVisualDatabaseDisplay::TabVisualDatabaseDisplay(TabSupervisor *_tabSupervisor void TabVisualDatabaseDisplay::retranslateUi() { + visualDatabaseDisplayWidget->retranslateUi(); } void TabVisualDatabaseDisplay::openEdhrecTab(const CardInfoPtr &info, bool isCommander) const { getTabSupervisor()->addEdhrecTab(info, isCommander); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index ecf59cfc0..5a1097b0d 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -116,7 +116,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); - displayModeButton = new QPushButton(tr("Visual"), this); + displayModeButton = new QPushButton(this); displayModeButton->setCheckable(true); // Toggle button connect(displayModeButton, &QPushButton::toggled, this, &VisualDatabaseDisplayWidget::onDisplayModeChanged); @@ -262,6 +262,10 @@ void VisualDatabaseDisplayWidget::retranslateUi() { databaseLoadIndicator->setText(tr("Loading database ...")); clearFilterWidget->setToolTip(tr("Clear all filters")); + searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); + updateDisplayModeButton(); + colorFilterWidget->retranslateUi(); + filterContainer->retranslateUi(); } void VisualDatabaseDisplayWidget::highlightAllSearchEdit() @@ -269,6 +273,11 @@ void VisualDatabaseDisplayWidget::highlightAllSearchEdit() searchEdit->setSelection(0, searchEdit->text().length()); } +void VisualDatabaseDisplayWidget::updateDisplayModeButton() +{ + displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual")); +} + void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); @@ -277,16 +286,15 @@ void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked) { + updateDisplayModeButton(); if (checked) { // Table mode - displayModeButton->setText(tr("Table")); flowWidget->setVisible(false); cardSizeWidget->setVisible(false); databaseView->setItemDelegate(new QStyledItemDelegate(databaseView)); databaseView->setVisible(true); } else { // Visual mode - displayModeButton->setText(tr("Visual")); flowWidget->setVisible(true); cardSizeWidget->setVisible(true); databaseView->setVisible(false); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 9771e55c1..15adca214 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -145,6 +145,7 @@ private: void initializeFilters(); void highlightAllSearchEdit(); + void updateDisplayModeButton(); bool nearEndOfPage() const; protected: From b4e24f958a8650ab77de0a35cee42e6bba0e17f4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:49:24 +0100 Subject: [PATCH 118/247] [Network] Do not format discarded debug messages, parse without memmove (#7606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Network] Do not format discarded debug messages, parse without memmove Protocol debug logging gets the full protobuf message rendered through TextFormat and reflection. On the server this happened before every session/room/game/moderator/admin/developer command, even though the default Server_ProtocolHandler::logDebugMessage() is a no-op and even when servatrice has no log file configured. On the clients the same string was built as an argument to qCDebug(), which evaluates its arguments regardless of whether the category is enabled. Server_ProtocolHandler now exposes a virtual logsDebugMessages() hook. The base implementation returns false, so handlers without a log destination spend nothing. AbstractServerSocketInterface overrides it with ServerLogger::isLoggingEnabled(), which checks the log file and the server/writelog setting that logMessage() itself consults. All debug message call sites check the hook before formatting. The client send/receive paths check QLoggingCategory::isDebugEnabled() first, the same pattern AbstractClient already uses for its slow-command warning. ISL message decoding does the same. Byte stream decoding called inputBuffer.remove(0, n) for the length header and then again for every message. Each call shifts all remaining bytes, so a read carrying k messages cost O(k * bytes remaining). The three receive loops (remote client, TCP server interface, ISL interface) now parse at a read offset and compact the consumed prefix once, when they stop because a full message is not buffered yet. No buffering or size-limit behavior changes. Also in this batch: - Server_ProtocolHandler::processGameCommandContainer() copied the whole games map under gameListMutex just to look up one game id. A new getGameInfo() performs the single lookup and returns the room/player ids. - The TCP receive loop's `ok` flag is initialized, so a ParseFromArray exception can no longer leave it indeterminate before `if (ok)`. - The exception log prints only the message being parsed instead of hex-dumping the entire pending buffer. Full build and ctest (628 tests) pass, including the server tests. * [Network] Address review comments - Move getGameInfo() out of the header into server_abstractuserinterface.cpp. - Drop the consumed prefix once after the parse loop instead of on every early return: the two "not enough data" exits now break out of the loop. Applied to the remote client, TCP server and ISL receive loops. --------- Co-authored-by: Lukas Brübach --- .../network/client/local/local_client.cpp | 8 ++- .../network/client/remote/remote_client.cpp | 48 +++++++++++------ .../remote/server_abstractuserinterface.cpp | 12 +++++ .../remote/server_abstractuserinterface.h | 7 +++ .../server/remote/server_protocolhandler.cpp | 35 +++++++----- .../server/remote/server_protocolhandler.h | 11 ++++ servatrice/src/isl_interface.cpp | 39 +++++++++----- servatrice/src/server_logger.cpp | 5 ++ servatrice/src/server_logger.h | 9 ++++ servatrice/src/serversocketinterface.cpp | 54 ++++++++++++------- servatrice/src/serversocketinterface.h | 1 + 11 files changed, 164 insertions(+), 65 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp index c1c1cc976..f7d8645e8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp @@ -39,14 +39,18 @@ LocalClient::~LocalClient() void LocalClient::sendCommandContainer(const CommandContainer &cont) { - qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); + if (LocalClientLog().isDebugEnabled()) { + qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); + } lsi->itemFromClient(cont); } void LocalClient::itemFromServer(const ServerMessage &item) { - qCDebug(LocalClientLog).noquote() << userName << "IN" << getSafeDebugString(item); + if (LocalClientLog().isDebugEnabled()) { + qCDebug(LocalClientLog).noquote() << userName << "IN" << getSafeDebugString(item); + } processProtocolItem(item); } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index cbf7dbda2..e02f5d3d8 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -496,9 +496,15 @@ void RemoteClient::readData() inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front of the buffer would memmove all of the + // remaining bytes once per message; instead the consumed prefix is dropped + // once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { + if (inputBuffer.size() - readOffset >= 4) { // dirty hack to be compatible with v14 server that sends 60 bytes of garbage at the beginning if (!handshakeStarted) { handshakeStarted = true; @@ -508,29 +514,30 @@ void RemoteClient::readData() } } else { // end of hack - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); - inputBuffer.remove(0, 4); + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); + readOffset += 4; messageInProgress = true; } } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } ServerMessage newServerMessage; - bool ok = newServerMessage.ParseFromArray(inputBuffer.data(), messageLength); + bool ok = newServerMessage.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); - inputBuffer.remove(0, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { - qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + } processProtocolItem(newServerMessage); } else { @@ -540,7 +547,12 @@ void RemoteClient::readData() if (getStatus() == StatusDisconnecting) { // use thread-safe getter doDisconnectFromServer(); } - } while (!inputBuffer.isEmpty()); + } + + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } void RemoteClient::websocketMessageReceived(const QByteArray &message) @@ -548,7 +560,9 @@ void RemoteClient::websocketMessageReceived(const QByteArray &message) lastDataReceived = timeRunning; ServerMessage newServerMessage; if (newServerMessage.ParseFromArray(message.data(), message.length())) { - qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + } processProtocolItem(newServerMessage); } else { @@ -564,7 +578,9 @@ void RemoteClient::sendCommandContainer(const CommandContainer &cont) auto size = static_cast(cont.ByteSize()); #endif - qCDebug(RemoteClientLog).noquote() << "OUT" << getSafeDebugString(cont); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "OUT" << getSafeDebugString(cont); + } QByteArray buf; bool ok; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp index 25aa415fc..c3524bb93 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp @@ -84,6 +84,18 @@ void Server_AbstractUserInterface::onLogin(ResponseContainer &) { } +bool Server_AbstractUserInterface::getGameInfo(int gameId, int &roomId, int &playerId) const +{ + QMutexLocker locker(&gameListMutex); + const auto it = games.constFind(gameId); + if (it == games.constEnd()) { + return false; + } + roomId = it.value().first; + playerId = it.value().second; + return true; +} + void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game) { qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h index a19475e34..a2a4ed8c7 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h @@ -58,6 +58,13 @@ public: return games; } + /** + * @brief Looks up the room and player id of one game without copying the game list. + * + * @return True if the game is known; otherwise false and the outputs are untouched. + */ + bool getGameInfo(int gameId, int &roomId, int &playerId) const; + virtual void sendProtocolItem(const Response &item) = 0; virtual void sendProtocolItem(const SessionEvent &item) = 0; virtual void sendProtocolItem(const GameEventContainer &item) = 0; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index eb2b2fc1c..dd074a517 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -208,7 +208,7 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co Response::ResponseCode resp = Response::RespInvalidCommand; const SessionCommand &sc = cont.session_command(i); const int num = getPbExtension(sc); - if (num != SessionCommand::PING) { // don't log ping commands + if (num != SessionCommand::PING && logsDebugMessages()) { // don't log ping commands logDebugMessage(getSafeDebugString(sc)); } const auto commandType = static_cast(num); @@ -271,7 +271,9 @@ Response::ResponseCode Server_ProtocolHandler::processRoomCommandContainer(const Response::ResponseCode resp = Response::RespInvalidCommand; const RoomCommand &sc = cont.room_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } switch ((RoomCommand::RoomCommandType)num) { case RoomCommand::LEAVE_ROOM: resp = cmdLeaveRoom(sc.GetExtension(Command_LeaveRoom::ext), room, rc); @@ -320,14 +322,14 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const return Response::RespLoginNeeded; } - QMap> gameMap = getGames(); - if (!gameMap.contains(cont.game_id())) { + int roomId; + int playerId; + if (!getGameInfo(cont.game_id(), roomId, playerId)) { return Response::RespNotInRoom; } - const QPair roomIdAndPlayerId = gameMap.value(cont.game_id()); QReadLocker roomsLocker(&server->roomsLock); - Server_Room *room = server->getRooms().value(roomIdAndPlayerId.first); + Server_Room *room = server->getRooms().value(roomId); if (!room) { return Response::RespNotInRoom; } @@ -337,14 +339,14 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const if (!game) { if (room->getExternalGames().contains(cont.game_id())) { server->sendIsl_GameCommand(cont, room->getExternalGames().value(cont.game_id()).server_id(), - userInfo->session_id(), roomIdAndPlayerId.first, roomIdAndPlayerId.second); + userInfo->session_id(), roomId, playerId); return Response::RespNothing; } return Response::RespNotInRoom; } QMutexLocker gameLocker(&game->gameMutex); - auto *participant = game->getParticipants().value(roomIdAndPlayerId.second); + auto *participant = game->getParticipants().value(playerId); if (!participant) { return Response::RespNotInRoom; } @@ -357,8 +359,9 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const Response::ResponseCode finalResponseCode = Response::RespOk; for (int i = cont.game_command_size() - 1; i >= 0; --i) { const GameCommand &sc = cont.game_command(i); - logDebugMessage(QString("game %1 player %2: ").arg(cont.game_id()).arg(roomIdAndPlayerId.second) + - getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(QString("game %1 player %2: ").arg(cont.game_id()).arg(playerId) + getSafeDebugString(sc)); + } if (commandCountingInterval > 0) { int totalCount = 0; @@ -407,7 +410,9 @@ Response::ResponseCode Server_ProtocolHandler::processModeratorCommandContainer( Response::ResponseCode resp = Response::RespInvalidCommand; const ModeratorCommand &sc = cont.moderator_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedModeratorCommand(num, sc, rc); if (resp != Response::RespOk) { @@ -434,7 +439,9 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons Response::ResponseCode resp = Response::RespInvalidCommand; const AdminCommand &sc = cont.admin_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedAdminCommand(num, sc, rc); if (resp != Response::RespOk) { @@ -461,7 +468,9 @@ Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer( Response::ResponseCode resp = Response::RespInvalidCommand; const DeveloperCommand &sc = cont.developer_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedDeveloperCommand(num, sc, rc); if (resp != Response::RespOk) { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index c0a1ffe9a..266c4b26c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -55,6 +55,17 @@ protected: virtual void logDebugMessage(const QString & /* message */) { } + /** + * @brief Whether debug messages produced by this handler are actually written anywhere. + * + * Formatting a protocol message for the log is expensive, so callers only + * build the debug string when this returns true. The base handler has no + * log destination and therefore never needs one. + */ + [[nodiscard]] virtual bool logsDebugMessages() const + { + return false; + } private: QList messageSizeOverTime, messageCountOverTime, commandCountOverTime; diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 8733c9a40..7121bfa48 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -264,13 +264,17 @@ void IslInterface::readClient() server->incRxBytes(data.size()); inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front would memmove the remaining bytes once + // per message; the consumed prefix is dropped once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); + if (inputBuffer.size() - readOffset >= 4) { + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); // Reject implausible lengths instead of buffering until they arrive. // This also handles lengths that overflow into negative int values. if (messageLength < 0 || messageLength > MAX_ISL_MESSAGE_SIZE) { @@ -278,19 +282,19 @@ void IslInterface::readClient() socket->disconnectFromHost(); return; } - inputBuffer.remove(0, 4); + readOffset += 4; messageInProgress = true; } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } IslMessage newMessage; - bool ok = newMessage.ParseFromArray(inputBuffer.data(), messageLength); - inputBuffer.remove(0, messageLength); + bool ok = newMessage.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { @@ -298,7 +302,12 @@ void IslInterface::readClient() } else { qCWarning(IslInterfaceLog) << "parsing error!"; } - } while (!inputBuffer.isEmpty()); + } + + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } void IslInterface::catchSocketError(QAbstractSocket::SocketError socketError) @@ -495,7 +504,9 @@ void IslInterface::processRoomCommand(const CommandContainer &cont, qint64 sessi void IslInterface::processMessage(const IslMessage &item) { - qCDebug(IslInterfaceLog) << getSafeDebugString(item); + if (IslInterfaceLog().isDebugEnabled()) { + qCDebug(IslInterfaceLog) << getSafeDebugString(item); + } switch (item.message_type()) { case IslMessage::ROOM_COMMAND_CONTAINER: { diff --git a/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index 1972444f1..1c83299b8 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -54,6 +54,11 @@ void ServerLogger::startLog(const QString &logFileName) connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection); } +bool ServerLogger::isLoggingEnabled() const +{ + return logFile != nullptr && settingsCache->value("server/writelog", 1).toBool(); +} + void ServerLogger::logMessage(const QString &message, void *caller) { if (!logFile) { diff --git a/servatrice/src/server_logger.h b/servatrice/src/server_logger.h index f2875a6a3..e0be4b712 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -16,6 +16,15 @@ class ServerLogger : public QObject public: ServerLogger(bool _logToConsole, QObject *parent = 0); ~ServerLogger(); + + /** + * @brief Whether messages passed to logMessage() would be written anywhere. + * + * Callers use this to avoid formatting expensive debug payloads that + * logMessage() would discard. + */ + [[nodiscard]] bool isLoggingEnabled() const; + public slots: void startLog(const QString &logFileName); void logMessage(const QString &message, void *caller = 0); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 890a93fbc..fc911950e 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -265,6 +265,11 @@ void AbstractServerSocketInterface::logDebugMessage(const QString &message) logger->logMessage(message, this); } +bool AbstractServerSocketInterface::logsDebugMessages() const +{ + return logger != nullptr && logger->isLoggingEnabled(); +} + void AbstractServerSocketInterface::processCommandContainer(const CommandContainer &cont) { QElapsedTimer timer; @@ -4431,13 +4436,17 @@ void TcpServerSocketInterface::readClient() servatrice->incRxBytes(data.size()); inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front would memmove the remaining bytes once + // per message; the consumed prefix is dropped once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); + if (inputBuffer.size() - readOffset >= 4) { + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); // Reject implausible lengths instead of buffering until they arrive. // This also handles lengths that overflow into negative int values. if (messageLength < 0 || messageLength > MAX_TCP_MESSAGE_SIZE) { @@ -4446,32 +4455,33 @@ void TcpServerSocketInterface::readClient() prepareDestroy(); return; } - inputBuffer.remove(0, 4); + readOffset += 4; messageInProgress = true; } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } CommandContainer newCommandContainer; - bool ok; + bool ok = false; try { - ok = newCommandContainer.ParseFromArray(inputBuffer.data(), messageLength); + ok = newCommandContainer.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); } catch (std::exception &e) { - qCWarning(TcpServerSocketInterfaceLog) << "Caught std::exception in" << __FILE__ << __LINE__ << + qCWarning(TcpServerSocketInterfaceLog) + << "Caught std::exception in" << __FILE__ << __LINE__ << #ifdef _MSC_VER // Visual Studio __FUNCTION__ #else __PRETTY_FUNCTION__ #endif - << Qt::endl - << "Exception:" << e.what() << Qt::endl - << "Message coming from:" << getAddress() << Qt::endl - << "Message length:" << messageLength << Qt::endl - << "Message content:" << inputBuffer.toHex(); + << Qt::endl + << "Exception:" << e.what() << Qt::endl + << "Message coming from:" << getAddress() << Qt::endl + << "Message length:" << messageLength << Qt::endl + << "Message content:" << inputBuffer.mid(readOffset, messageLength).toHex(); } catch (...) { qCWarning(TcpServerSocketInterfaceLog) << "Unhandled exception in" << __FILE__ << __LINE__ << #ifdef _MSC_VER // Visual Studio @@ -4482,7 +4492,7 @@ void TcpServerSocketInterface::readClient() << Qt::endl << "Message coming from:" << getAddress(); } - inputBuffer.remove(0, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { @@ -4499,8 +4509,12 @@ void TcpServerSocketInterface::readClient() } else { qCWarning(TcpServerSocketInterfaceLog) << "parsing error!"; } + } - } while (!inputBuffer.isEmpty()); + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } bool TcpServerSocketInterface::initTcpSession() diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index dd9d3f2bb..71854513f 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -123,6 +123,7 @@ signals: protected: void logDebugMessage(const QString &message) override; + [[nodiscard]] bool logsDebugMessages() const override; bool tooManyRegistrationAttempts(const QString &ipAddress); void processCommandContainer(const CommandContainer &cont) override; bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const; From 93309e01a2eece1f8151b78f31aea93fb5b699a1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:19:20 +0100 Subject: [PATCH 119/247] [Server/Client/Protocol] Let moderators clear banner cards (#7546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../widgets/tabs/tab_card_art_rules.cpp | 56 +++++++++- .../widgets/tabs/tab_card_art_rules.h | 3 + .../interface/widgets/tabs/tab_moderation.cpp | 93 +++++++++++++--- .../interface/widgets/tabs/tab_moderation.h | 5 +- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 + .../protocol/pb/moderator_commands.proto | 15 +++ .../response_clear_blacklisted_banners.proto | 9 ++ .../pb/response_remove_user_banner.proto | 11 ++ .../src/servatrice_database_interface.cpp | 102 ++++++++++++++++++ .../src/servatrice_database_interface.h | 12 +++ servatrice/src/serversocketinterface.cpp | 67 ++++++++++++ servatrice/src/serversocketinterface.h | 5 + 12 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp index 1fc2854ce..f18aa82b9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -29,7 +30,9 @@ #include #include #include +#include #include +#include #include class QObject; @@ -172,6 +175,7 @@ void TabCardArtRules::setupUi() addBtn = new QPushButton; removeBtn = new QPushButton; refreshBtn = new QPushButton; + clearBannedArtBtn = new QPushButton; modeBox->addItems({"ALLOW", "DENY"}); @@ -188,10 +192,14 @@ void TabCardArtRules::setupUi() form->addRow(tr("Mode:"), modeBox); form->addRow(tr("Reason:"), reasonEdit); + // The bulk clear acts on every user, so keep it visibly apart from the + // rules this tab edits. auto *buttons = new QHBoxLayout; buttons->addWidget(addBtn); buttons->addWidget(removeBtn); buttons->addWidget(refreshBtn); + buttons->addStretch(); + buttons->addWidget(clearBannedArtBtn); auto *layout = new QVBoxLayout; layout->addLayout(form); @@ -207,6 +215,8 @@ void TabCardArtRules::setupUi() connect(refreshBtn, &QPushButton::clicked, this, &TabCardArtRules::refresh); + connect(clearBannedArtBtn, &QPushButton::clicked, this, &TabCardArtRules::clearBlacklistedBanners); + retranslateUi(); } @@ -263,6 +273,7 @@ void TabCardArtRules::retranslateUi() addBtn->setText(tr("Add rule")); removeBtn->setText(tr("Remove rule")); refreshBtn->setText(tr("Refresh")); + clearBannedArtBtn->setText(tr("Clear blacklisted banners")); } void TabCardArtRules::refresh() @@ -299,4 +310,47 @@ void TabCardArtRules::removeSelected() client->sendCommand(client->prepareModeratorCommand(cmd)); refresh(); -} \ No newline at end of file +} + +void TabCardArtRules::clearBlacklistedBanners() +{ + if (!QtUtils::confirm(this, tr("Clear blacklisted banners"), + tr("Remove the banner card from every user whose current banner matches a DENY rule?\n\n" + "Users who are online keep showing their banner until they log in again."), + QMessageBox::No)) { + return; + } + + Command_ClearBlacklistedBanners cmd; + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabCardArtRules::clearBlacklistedBannersResponse); + client->sendCommand(pend); +} + +void TabCardArtRules::clearBlacklistedBannersResponse(const Response &r) +{ + switch (r.response_code()) { + case Response::RespOk: + break; + case Response::RespFunctionNotAllowed: + QMessageBox::warning(this, tr("Not supported"), + tr("This server does not support clearing blacklisted banners. It may be running an " + "older version.")); + return; + case Response::RespInternalError: + QMessageBox::critical(this, tr("Error"), + tr("The server could not clear blacklisted banners because of a database error.")); + return; + default: + QMessageBox::critical(this, tr("Error"), + tr("The server rejected the request: %1") + .arg(QString::fromStdString(Response_ResponseCode_Name(r.response_code())))); + return; + } + + const auto &resp = r.GetExtension(Response_ClearBlacklistedBanners::ext); + QMessageBox::information(this, tr("Banners cleared"), + tr("%n banner card(s) cleared.\n\n" + "Users who are online keep showing their banner until they log in again.", + "number of banners cleared", resp.cleared_count())); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h index f34300179..1e0155ab7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -78,6 +78,8 @@ private slots: void addRule(); void removeSelected(); void refresh(); + void clearBlacklistedBanners(); + void clearBlacklistedBannersResponse(const Response &r); private: AbstractClient *client; @@ -97,6 +99,7 @@ private: QPushButton *addBtn; QPushButton *removeBtn; QPushButton *refreshBtn; + QPushButton *clearBannedArtBtn; QTableView *table; CardArtRulesModel *tableModel; diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 09a640540..be1adeef8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -30,12 +30,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -85,12 +87,16 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli removeAvatarButton = new QPushButton; connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar); + removeBannerButton = new QPushButton; + connect(removeBannerButton, &QPushButton::clicked, this, &TabModeration::removeBanner); + auto *topBar = new QHBoxLayout; topBar->addWidget(searchEdit); topBar->addWidget(investigateButton); topBar->addStretch(); topBar->addWidget(resetPasswordButton); topBar->addWidget(removeAvatarButton); + topBar->addWidget(removeBannerButton); userInfoGroup = new QGroupBox; userInfoNameLabel = new QLabel; @@ -186,6 +192,7 @@ void TabModeration::retranslateUi() investigateButton->setText(tr("Investigate")); resetPasswordButton->setText(tr("Reset Password")); removeAvatarButton->setText(tr("Remove Avatar")); + removeBannerButton->setText(tr("Remove Banner")); refreshStaffButton->setText(tr("Refresh")); userInfoGroup->setTitle(tr("User Info")); @@ -227,6 +234,7 @@ void TabModeration::clearUserData() sessionsTable->setRowCount(0); resetPasswordButton->setEnabled(false); removeAvatarButton->setEnabled(false); + removeBannerButton->setEnabled(false); } void TabModeration::investigate(const QString &userName) @@ -247,6 +255,7 @@ void TabModeration::investigateUser() currentUser = userName; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); + removeBannerButton->setEnabled(true); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -414,13 +423,11 @@ void TabModeration::resetPassword() return; } - QMessageBox::StandardButton choice = - QMessageBox::warning(this, tr("Reset Password"), - tr("Reset the password of %1? A temporary password will be generated and shown to you. " - "The user must change it on their first login.") - .arg(currentUser), - QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); - if (choice != QMessageBox::Ok) { + if (!QtUtils::confirm(this, tr("Reset Password"), + tr("Reset the password of %1? A temporary password will be generated and shown to you. " + "The user must change it on their first login.") + .arg(currentUser), + QMessageBox::No)) { return; } @@ -451,11 +458,9 @@ void TabModeration::removeAvatar() return; } - QMessageBox::StandardButton choice = - QMessageBox::warning(this, tr("Remove Avatar"), - tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), - QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); - if (choice != QMessageBox::Ok) { + if (!QtUtils::confirm(this, tr("Remove Avatar"), + tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), + QMessageBox::No)) { return; } @@ -477,3 +482,67 @@ void TabModeration::removeAvatarResponse(const Response &response) QMessageBox::information(this, tr("Avatar removed"), tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name()))); } + +void TabModeration::removeBanner() +{ + if (currentUser.isEmpty()) { + return; + } + + if (!QtUtils::confirm(this, tr("Remove Banner"), + tr("Remove the banner card of %1? The user will have to choose a new one.\n\n" + "Other players stop seeing the banner the next time %1 logs in.") + .arg(currentUser), + QMessageBox::No)) { + return; + } + + Command_RemoveUserBanner cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::removeBannerResponse); + client->sendCommand(pend); +} + +void TabModeration::removeBannerResponse(const Response &response) +{ + const QString target = currentUser.isEmpty() ? tr("this user") : currentUser; + + switch (response.response_code()) { + case Response::RespOk: + break; + case Response::RespNameNotFound: + QMessageBox::warning(this, tr("User not found"), + tr("The server does not know a user named %1.").arg(target)); + return; + case Response::RespFunctionNotAllowed: + QMessageBox::warning(this, tr("Not supported"), + tr("This server does not support removing banner cards. It may be running an older " + "version.")); + return; + case Response::RespInternalError: + QMessageBox::critical( + this, tr("Error"), + tr("The server could not clear the banner card of %1 because of a database error.").arg(target)); + return; + default: + QMessageBox::critical( + this, tr("Error"), + tr("The server rejected the request for %1: %2") + .arg(target, QString::fromStdString(Response_ResponseCode_Name(response.response_code())))); + return; + } + + const Response_RemoveUserBanner &resp = response.GetExtension(Response_RemoveUserBanner::ext); + const QString removedUser = resp.user_name().empty() ? target : QString::fromStdString(resp.user_name()); + if (!resp.had_banner()) { + QMessageBox::information(this, tr("No banner set"), + tr("%1 has no banner card set, so there was nothing to remove.").arg(removedUser)); + return; + } + + QMessageBox::information(this, tr("Banner removed"), + tr("The banner card of %1 has been removed.\n\n" + "Other players stop seeing it the next time %1 logs in.") + .arg(removedUser)); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index e352e90bb..ce23b5a7a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -20,7 +20,7 @@ class TabSupervisor; /** * Staff investigation tool. Lets moderators look up a user's account data, * alternate accounts, login sessions, and staff login activity, and offers - * the password-reset and remove-avatar actions. + * the password-reset, remove-avatar, and remove-banner actions. */ class TabModeration : public Tab { @@ -44,6 +44,8 @@ private slots: void resetPasswordResponse(const Response &response); void removeAvatar(); void removeAvatarResponse(const Response &response); + void removeBanner(); + void removeBannerResponse(const Response &response); private: void requestUserInfo(const QString &userName); @@ -60,6 +62,7 @@ private: QPushButton *investigateButton; QPushButton *resetPasswordButton; QPushButton *removeAvatarButton; + QPushButton *removeBannerButton; QGroupBox *userInfoGroup; QLabel *userInfoNameLabel; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index bd0b5bda2..059e83096 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -174,6 +174,8 @@ set(PROTO_FILES response_report_user_info.proto response_moderator_last_logins.proto response_remove_user_avatar.proto + response_remove_user_banner.proto + response_clear_blacklisted_banners.proto response_reset_user_password.proto response_user_alts.proto response_user_sessions.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 5298d3dad..292917fa9 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -20,6 +20,8 @@ message ModeratorCommand { GET_MODERATOR_LAST_LOGINS = 1015; RESET_USER_PASSWORD = 1016; REMOVE_USER_AVATAR = 1017; + REMOVE_USER_BANNER = 1018; + CLEAR_BLACKLISTED_BANNERS = 1019; REPORT_LIST = 1200; REPORT_ASSIGN = 1201; REPORT_RESOLVE = 1202; @@ -179,3 +181,16 @@ message Command_RemoveUserAvatar { } optional string user_name = 1; } + +message Command_RemoveUserBanner { + extend ModeratorCommand { + optional Command_RemoveUserBanner ext = 1018; + } + optional string user_name = 1; +} + +message Command_ClearBlacklistedBanners { + extend ModeratorCommand { + optional Command_ClearBlacklistedBanners ext = 1019; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto new file mode 100644 index 000000000..86cbc92f3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ClearBlacklistedBanners { + extend Response { + optional Response_ClearBlacklistedBanners ext = 1222; + } + optional uint32 cleared_count = 1 [default = 0]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto new file mode 100644 index 000000000..bfad7333c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_RemoveUserBanner { + extend Response { + optional Response_RemoveUserBanner ext = 1221; + } + optional string user_name = 1; + // false when the user exists but had no banner card set, so nothing was cleared + optional bool had_banner = 2 [default = true]; +} diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 04f6047fa..b5c45065c 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -2077,6 +2077,108 @@ bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) return query->numRowsAffected() > 0; } +Servatrice_DatabaseInterface::RemoveUserBannerResult +Servatrice_DatabaseInterface::removeUserBanner(const QString &userName) +{ + if (!checkSql()) { + return RemoveUserBannerResult::DatabaseError; + } + + // The UPDATE alone cannot tell a missing user from a user without a banner, + // so read the row first and let the moderator know which case they hit. + QSqlQuery *lookupQuery = prepareQuery("SELECT card_art_params FROM {prefix}_users WHERE name = :user_name"); + lookupQuery->bindValue(":user_name", userName); + if (!execSqlQuery(lookupQuery)) { + return RemoveUserBannerResult::DatabaseError; + } + if (!lookupQuery->next()) { + return RemoveUserBannerResult::NoSuchUser; + } + if (lookupQuery->value(0).toString().isEmpty()) { + return RemoveUserBannerResult::NothingToRemove; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE name = :user_name"); + query->bindValue(":user_name", userName); + if (!execSqlQuery(query)) { + return RemoveUserBannerResult::DatabaseError; + } + return RemoveUserBannerResult::Removed; +} + +// isCardArtAllowed() compares against the rule table with the database's +// case-insensitive collation, so the sweep has to fold case the same way or it +// leaves behind banners the server already treats as blacklisted. +static QPair bannerKey(const QString &cardName, const QString &cardProviderId) +{ + return {cardName.toCaseFolded(), cardProviderId.toCaseFolded()}; +} + +int Servatrice_DatabaseInterface::clearBlacklistedBanners() +{ + if (!checkSql()) { + return -1; + } + + QSqlQuery *ruleQuery = prepareQuery("SELECT card_name, card_provider_id FROM {prefix}_card_art_name_rules " + "WHERE mode = 'DENY'"); + if (!execSqlQuery(ruleQuery)) { + return -1; + } + + QSet> denied; + while (ruleQuery->next()) { + denied.insert(bannerKey(ruleQuery->value(0).toString(), ruleQuery->value(1).toString())); + } + if (denied.isEmpty()) { + return 0; + } + + QSqlQuery *userQuery = + prepareQuery("SELECT id, card_art_params FROM {prefix}_users WHERE card_art_params IS NOT NULL"); + if (!execSqlQuery(userQuery)) { + return -1; + } + + QList matchingIds; + while (userQuery->next()) { + const QJsonDocument doc = QJsonDocument::fromJson(userQuery->value(1).toString().toUtf8()); + if (!doc.isObject()) { + continue; + } + const QJsonObject obj = doc.object(); + if (denied.contains(bannerKey(obj.value("card_name").toString(), obj.value("card_provider_id").toString()))) { + matchingIds.append(userQuery->value(0)); + } + } + + if (matchingIds.isEmpty()) { + return 0; + } + + // Chunk the UPDATE so very large user tables do not exceed driver placeholder limits. + int cleared = 0; + constexpr int BATCH_SIZE = 500; + for (int i = 0; i < matchingIds.size(); i += BATCH_SIZE) { + const QList batch = matchingIds.mid(i, BATCH_SIZE); + QStringList placeholders; + placeholders.reserve(batch.size()); + for (int j = 0; j < batch.size(); ++j) { + placeholders.append(QString(":id%1").arg(j)); + } + QSqlQuery *clearQuery = prepareQuery( + QString("UPDATE {prefix}_users SET card_art_params = NULL WHERE id IN (%1)").arg(placeholders.join(","))); + for (int j = 0; j < batch.size(); ++j) { + clearQuery->bindValue(placeholders.at(j), batch.at(j)); + } + if (!execSqlQuery(clearQuery)) { + return -1; + } + cleared += clearQuery->numRowsAffected(); + } + return cleared; +} + QList Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 822accada..6b38384e4 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -231,6 +231,18 @@ public: }; UptimeSnapshot getLatestUptimeSnapshot(int serverId); bool removeUserAvatar(const QString &userName); + + // Outcome of removeUserBanner(), so the caller can tell a missing user apart + // from a user who simply has no banner card set. + enum class RemoveUserBannerResult + { + Removed, + NothingToRemove, + NoSuchUser, + DatabaseError, + }; + RemoveUserBannerResult removeUserBanner(const QString &userName); + int clearBlacklistedBanners(); bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override; bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index fc911950e..2cb4df8ba 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -126,6 +126,7 @@ #include #include #include +#include #include #include #include @@ -140,6 +141,7 @@ #include #include #include +#include #include #include #include @@ -484,6 +486,10 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetModeratorLastLogins(cmd.GetExtension(Command_GetModeratorLastLogins::ext), rc); case ModeratorCommand::REMOVE_USER_AVATAR: return cmdRemoveUserAvatar(cmd.GetExtension(Command_RemoveUserAvatar::ext), rc); + case ModeratorCommand::REMOVE_USER_BANNER: + return cmdRemoveUserBanner(cmd.GetExtension(Command_RemoveUserBanner::ext), rc); + case ModeratorCommand::CLEAR_BLACKLISTED_BANNERS: + return cmdClearBlacklistedBanners(cmd.GetExtension(Command_ClearBlacklistedBanners::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -2691,6 +2697,67 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserBanner(const Command_RemoveUserBanner &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + bool hadBanner = true; + switch (sqlInterface->removeUserBanner(userName)) { + case Servatrice_DatabaseInterface::RemoveUserBannerResult::Removed: + break; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::NothingToRemove: + hadBanner = false; + break; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::NoSuchUser: + return Response::RespNameNotFound; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::DatabaseError: + default: + return Response::RespInternalError; + } + + if (hadBanner) { + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_BANNER", "Moderator removed user banner card", true); + } + + Response_RemoveUserBanner *re = new Response_RemoveUserBanner; + re->set_user_name(userName.toStdString()); + re->set_had_banner(hadBanner); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode +AbstractServerSocketInterface::cmdClearBlacklistedBanners(const Command_ClearBlacklistedBanners & /* cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const int cleared = sqlInterface->clearBlacklistedBanners(); + if (cleared < 0) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(QString::fromStdString(userInfo->name()), this->getAddress(), + QString::fromStdString(userInfo->clientid()), "CLEAR_BLACKLISTED_BANNERS", + QString("Moderator cleared %1 blacklisted banner(s)").arg(cleared), true); + + Response_ClearBlacklistedBanners *re = new Response_ClearBlacklistedBanners; + re->set_cleared_count(static_cast(cleared)); + rc.setResponseExtension(re); + return Response::RespOk; +} + void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) { if (!sqlInterface->checkSql()) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 71854513f..cd37286c6 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -73,6 +73,7 @@ class Command_AccountPassword; class Command_Activate; class Command_AddCardArtRule; class Command_AdjustMod; +class Command_ClearBlacklistedBanners; class Command_ForceActivateUser; class Command_ForgotPasswordChallenge; class Command_ForgotPasswordRequest; @@ -90,6 +91,7 @@ class Command_ListCardArtRules; class Command_Register; class Command_RemoveCardArtRule; class Command_RemoveUserAvatar; +class Command_RemoveUserBanner; class Command_Report; class Command_ReportAddComment; class Command_ReportAssign; @@ -236,6 +238,9 @@ private: Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc); Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + Response::ResponseCode cmdRemoveUserBanner(const Command_RemoveUserBanner &cmd, ResponseContainer &rc); + Response::ResponseCode cmdClearBlacklistedBanners(const Command_ClearBlacklistedBanners &cmd, + ResponseContainer &rc); Response::ResponseCode cmdGetServerStats(const Command_GetServerStats &cmd, ResponseContainer &rc); From f3fb3331d141da686da07a3dd560b2391293acb1 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:35:10 +0100 Subject: [PATCH 120/247] [Cards] Guard against null QScreen when positioning enlarged pixmap (#7650) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../interface/widgets/cards/card_info_picture_widget.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 3c1e1b77f..0d94e7209 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -491,7 +491,14 @@ void CardInfoPictureWidget::showEnlargedPixmap() enlargedPixmapWidget->setCardPixmap(exactCard, enlargedSize); const QPoint cursorPos = QCursor::pos(); - const QRect screenGeometry = QGuiApplication::screenAt(cursorPos)->geometry(); + QScreen *screen = QGuiApplication::screenAt(cursorPos); + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + if (!screen) { + return; + } + const QRect screenGeometry = screen->geometry(); const QSize widgetSize = enlargedPixmapWidget->size(); int newX = cursorPos.x() + ENLARGED_PIXMAP_OFFSET; From 4ab875be48fb198572075f03ce192157e1d3864d Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:35:46 +0100 Subject: [PATCH 121/247] [VisualDeckStorage] Keep databaseLoadIndicator alive when swapping in folder widget (#7651) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../visual_deck_storage_widget.cpp | 12 +++++++++++- .../visual_deck_storage/visual_deck_storage_widget.h | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index efcf19e58..4e1d6293f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -184,7 +184,9 @@ void VisualDeckStorageWidget::resizeEvent(QResizeEvent *event) void VisualDeckStorageWidget::retranslateUi() { - databaseLoadIndicator->setText(tr("Loading database ...")); + if (databaseLoadIndicator) { + databaseLoadIndicator->setText(tr("Loading database ...")); + } refreshButton->setToolTip(tr("Refresh loaded files")); shareButton->setToolTip(tr("Select decks to share")); @@ -279,6 +281,14 @@ void VisualDeckStorageWidget::createRootFolderWidget() folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); + // QScrollArea::setWidget() deletes the widget it currently holds. If that is still the + // database load indicator, take it back first so the label stays alive for retranslateUi(). + if (databaseLoadIndicator && scrollArea->widget() == databaseLoadIndicator) { + scrollArea->takeWidget(); + databaseLoadIndicator->setParent(this); + databaseLoadIndicator->setVisible(false); + } + scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index b512dd87c..19d16f107 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -6,6 +6,7 @@ #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H +#include #include #include #include @@ -87,7 +88,7 @@ private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; - QLabel *databaseLoadIndicator; + QPointer databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget; From e6b4c515850adc2bb4def5a1cbc3ec5ef0937f59 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:14:21 +0100 Subject: [PATCH 122/247] [Version] Bump to 3.2.0 for beta release. (#7653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 41fe393e7..284c1b9b0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.1.2) +project("Cockatrice" VERSION 3.2.0) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) From 4d8ab54313dfcd58c0f3c35a274524ad1b336664 Mon Sep 17 00:00:00 2001 From: ebbit1q Date: Sun, 25 Oct 2026 20:19:23 +0100 Subject: [PATCH 123/247] Revert "[Version] Bump to 3.2.0 for beta release. (#7653)" (#7660) This reverts commit e6b4c515850adc2bb4def5a1cbc3ec5ef0937f59. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 284c1b9b0..41fe393e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.2.0) +project("Cockatrice" VERSION 3.1.2) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) From 858b270fa9637a6fc6d08a29ae0fa89ab9c261e2 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:40:57 +0100 Subject: [PATCH 124/247] [Installer] Retry locked DLL purge before aborting upgrade (#7657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Retry locked DLL purge before aborting upgrade * [Installer] Keep the purge verdict when the portable purge runs $IsUpgrade was read from IsExistingCockatriceInstall after the portable purge, which deletes the very markers that function looks for. A purge whose only survivors were non-Qt DLLs (abseil_dll.dll, libssl-3-x64.dll) or content under Plugins therefore reported "not an install", $IsUpgrade became 0 and the abort was skipped - the mixed DLL set the retry exists to prevent. Measure the directory before either purge block, where it still describes the directory as it arrived, and let RetryDllPurge's own verdict decide the abort. A portable install into a directory without portable.dat is neither purge block, so $StaleRuntimeAfterPurge was never assigned there and the abort could not fire either. Track whether a purge ran and measure that directory with HasStaleRuntimeFiles instead, which is what every upgrade did before the retry. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: BruebachL <44814898+BruebachL@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 104 ++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index ae156313d..12185adda 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -27,6 +27,11 @@ 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. @@ -153,6 +158,10 @@ 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 "" +; 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 @@ -1176,6 +1185,33 @@ Function CheckStaleRuntimeMask ${EndIf} 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. +; 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: + 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. ; @@ -1631,6 +1667,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 +1707,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 +1743,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,13 +1764,29 @@ ${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: 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 From 675e1f74471937c703e7a0c5098f34a06513244a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:39:26 +0100 Subject: [PATCH 125/247] [Translation] Name languages without reading the translation files (#7662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Translation] Name languages without reading the translation files The language selector derived every label from the language's own .qm file: it loaded the file and translated the literal "English" in the "i18n" context. lupdate could never extract that literal from the callers, which pass the DEFAULT_LANG_NAME macro, so a dummy QT_TRANSLATE_NOOP in cache_settings.cpp existed to give it something to extract. #7050 deleted that dummy, the next lupdate push dropped the string from Transifex, and the following pull rewrote the .ts files without it. lrelease only emits translated messages, so 22 of 25 .qm files returned an empty name. The names are data, not translations: a selector has to be readable before any translation is loaded, and a label must not depend on the translation of the language it names. TranslationLoader::languageDisplayName now holds them next to availableLanguages, falling back to Qt's native language name and then to the code, so a language added later still gets a label. Also select the configured language by item data instead of by matching the translated "English" string, which pointed at the wrong row whenever the lookup came back empty, and restore that English entry's own name: en_US is "English (United States)" so it can no longer be confused with the untranslated default. Oracle shared the code path, so its wizard language page is fixed too. * [Translation] Name the default language selection once Review follow-up: log the unresolvable language through the page's own category, and move the en_US entry both selectors fall back to for an unset setting into TranslationLoader::defaultLanguageCode. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/cache_settings.h | 1 - .../widgets/onboarding/pages/welcome_page.cpp | 3 +- .../settings_page/general_settings_page.cpp | 38 +++++-------- .../settings_page/general_settings_page.h | 1 - .../utility/translation_loader.cpp | 56 +++++++++++++++++++ .../utility/translation_loader.h | 20 +++++++ oracle/src/oraclewizard.cpp | 3 - oracle/src/pages.cpp | 35 +++++------- oracle/src/pages.h | 1 - tests/CMakeLists.txt | 1 + tests/translation_loader_test.cpp | 49 ++++++++++++++++ 11 files changed, 157 insertions(+), 51 deletions(-) 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/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/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 2b885237b..77abc699c 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); @@ -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()); 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/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/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/tests/CMakeLists.txt b/tests/CMakeLists.txt index e2c3252ee..62b735a50 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -406,6 +406,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) 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; From 4793c1fc044e07768beb6e50d569d52559d8c3ff Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:45:52 +0100 Subject: [PATCH 126/247] [Installer] Offer to close blocking processes and retry purge on upgrade (#7664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Offer to close blocking processes and retry purge on upgrade * [Installer] Retry the purge after closing only our own applications The first version of this fix closed every Restart Manager holder through RmShutdown, forcing what refused to close. RmShutdown also stops services, and the only API that brings them back is RmRestart, which would restart Cockatrice itself into the files that are about to be replaced; the forced pass also contradicts the running-instance guard, which never force-closes Cockatrice because it may be holding an unsaved deck. The guard ran first anyway, so for the case the fix exists for - a Cockatrice restarted during the update - the probe never got to run: the guard waited 60 s and aborted. Keep the prompt and the retry, but ask only Cockatrice, Oracle and Servatrice to close, with WM_CLOSE and nothing else, and leave every other holder named by the probe to the user. Repeat the prompt while the user keeps saying Yes, refresh the names after a failed retry, and keep the /R abort message pointing at the downloaded installer. Also clear the read-only attribute before every purge attempt. Delete and RMDir leave such a file in place, so the retry could never make progress, and the diagnostics branch that named the cause had no action behind it. The holder probe goes back to being diagnostic-only, so its Restart Manager code and the --close argument are dropped. * [Installer] Make an accepted close of the running client actually land WM_CLOSE is a request, and Cockatrice answers it: while a card database update is running, a game is open or a deck is unsaved it puts up its own "are you sure?" confirmation. That dialog opened behind the installer's prompt, so the user saw nothing happen; the next Yes was posted to a main window the app's own modal dialog had disabled, so it was ignored as well, and the identical yes/no prompt came back with no hint of why. - Activate the matching window before posting WM_CLOSE, so the app's own confirmation is in front where it can be answered. - Wait up to 30 s for cockatrice.exe to exit instead of purging after a fixed 2 s, so the purge measures the app gone rather than its dialog. - On the second and later rounds ask with Retry/Cancel and say what is needed, instead of repeating the same offer that just did nothing. - Stop promising Oracle and Servatrice: they are console applications, so CloseMainWindow() has nothing to send WM_CLOSE to. They are not force-stopped either - Oracle writes the card database in place. * [Installer] Close the client from the holder probe, not PowerShell Accepting the offer to close did nothing on a real machine: the instance stayed up, never came forward, and the same prompt came back. The prompt names its holders from the Restart Manager probe, but the close itself went through powershell.exe: Get-Process -Name, a strict "path starts with $INSTDIR" filter, then Process.CloseMainWindow() and a WScript.Shell AppActivate. Every step of that is silent. A process whose path cannot be read is filtered out, a process .NET finds no main window for makes CloseMainWindow() return false without posting anything, and AppActivate is refused for a background caller - so an attempted close, a missed close and a close request the client ignored all looked the same in the log: nothing. Ask the probe instead. installer-holder-probe --close-holders closes exactly the processes Restart Manager just named as holding a file of the version being replaced, so no executable path has to be guessed and a third party application that merely shares the image name is never in the set. Per instance it restores and brings the top-level window forward (AttachThreadInput, so the client's own "really quit?" confirmation is on screen rather than behind the installer), posts WM_CLOSE to the main window, and waits up to 30 s for the process to exit. Its report names every holder with what happened to it, including the ones left to the user, and goes into the installer log as one line. That also removes the separate PowerShell polling loop that used to wait for the close: the probe holds a handle on the process it asked, so the purge that follows measures a settled directory. Oracle and Servatrice are still never closed, and nothing is force-stopped. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 244 ++++++++++++++++-- tools/installer-holder-probe/CMakeLists.txt | 2 +- .../installer-holder-probe/close_holders.cpp | 212 +++++++++++++++ tools/installer-holder-probe/close_holders.h | 46 ++++ tools/installer-holder-probe/main.cpp | 45 +++- 5 files changed, 524 insertions(+), 25 deletions(-) create mode 100644 tools/installer-holder-probe/close_holders.cpp create mode 100644 tools/installer-holder-probe/close_holders.h diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 12185adda..6ce765a35 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -38,6 +38,9 @@ Var AbortReason Var HolderExit Var HolderOutput Var HolderFound +; OfferToCloseAndRetryPurge counts the accepted close-and-retry rounds, so the +; prompt can stop repeating an offer that provably did not help (see there). +Var HolderCloseRounds Var WaitPid Var LauncherExited Var InstallLogPath @@ -766,8 +769,16 @@ Function CloseMatchingApps ; 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. + ; + ; The window is activated first, because WM_CLOSE is answered, not obeyed: + ; Cockatrice replies with a confirmation of its own while a card database + ; update is running, a game is open or a deck is unsaved. Without the + ; activation that dialog opens behind the installer's own prompt, where it + ; looks like the close did nothing and the second WM_CLOSE is delivered to a + ; main window its own modal dialog has disabled, which is ignored. Failing to + ; activate is harmless and swallowed: the close request still goes out. 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() }"' + 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 { try { $$null = (New-Object -ComObject WScript.Shell).AppActivate($$_.Id) } catch { }; $$null = $$_.CloseMainWindow() }"' Pop $R3 FunctionEnd @@ -1185,15 +1196,74 @@ 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. +; 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 @@ -1253,12 +1323,14 @@ FunctionEnd ; ; 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. +; (?e), so a failure reports what Windows refused; clearing LastError first makes +; a zero code mean "the open worked". That is all it means: Windows lets a DLL a +; running process has mapped be opened for DELETE, and DeleteFile still refuses +; it until the image is unloaded, so a zero code is not evidence that the +; failure was transient - the holders line is what names a running process. 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. Function LogStaleRuntimeDiagnostics FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" ${If} $StaleFindHandle != "" @@ -1275,11 +1347,12 @@ 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. + ; A valid handle: close it. The open 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 (the purge clears it before every attempt + ; now, so a survivor that is read-only had it set again). 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 @@ -1287,9 +1360,9 @@ Function LogStaleRuntimeDiagnostics ${Else} 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" + StrCpy $R0 "a delete-access open now succeeds, but it is read-only, which stops Delete" ${Else} - StrCpy $R0 "a delete-access open now succeeds" + StrCpy $R0 "a delete-access open now succeeds, but a DLL a running process has mapped still refuses Delete - see holders" ${EndIf} ${EndIf} ${Else} @@ -1360,6 +1433,125 @@ Function LogProcessesUsingInstallDir !insertmacro CockLog "holders: $HolderOutput" FunctionEnd +; 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 - ask, close what this +; installer owns, purge again, and retry while the user keeps asking. Sets +; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts +; when the files survive. +; +; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the +; window's close button) and 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, exactly as the +; running-instance guard treats it. The instance is brought forward first, so that +; confirmation is on screen rather than behind this installer's prompt. Oracle and +; Servatrice have no window to send a close to at all, and nothing here is +; force-stopped: Oracle writes the card database in place and killing it mid-write +; would corrupt it. Everything the probe named that this installer cannot close - +; those two included - is left to the user, whom the prompt names it to. +; +; 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. +; +; No offer when nobody is at the keyboard (a /S install without /R, e.g. a package +; manager): a dialog there would block forever. /R is the in-app updater, whose +; client has already quit, so the user is there - the same reasoning AbortInstall +; uses for its message box. No offer either when the probe named nobody: there is +; nothing to close, and the abort's per-file diagnostics already record what +; Windows said. +; +; The first round offers the close; every later one stops offering it and says +; what to do instead. Repeating the same question is what made an accepted close +; look like a Yes that was ignored: when Cockatrice was blocked on its own +; confirmation, the second Yes was delivered to a window that dialog had disabled, +; so nothing closed, the holders were unchanged, and the identical prompt came +; back with no hint of what had happened. +Function OfferToCloseAndRetryPurge + ${If} $ReinstallMode = 0 + ${AndIf} ${Silent} + !insertmacro CockLog "close-holders: unattended install, not offering to close holders" + Return + ${EndIf} + ${If} $HolderFound != 1 + !insertmacro CockLog "close-holders: no holder was named, nothing to offer" + Return + ${EndIf} + StrCpy $HolderCloseRounds 0 + ocrp_prompt: + ${If} $HolderCloseRounds = 0 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nThe installer can close Cockatrice for you. It may first ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose Yes to close Cockatrice and try again, or No to cancel the installation." \ + IDYES ocrp_close + !insertmacro CockLog "close-holders: declined by user" + Return + ${EndIf} + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nCockatrice may be waiting for an answer in its own window - it asks before quitting while a card database update is running, a game is open or a deck is unsaved. Switch to it and confirm, then close Oracle, Servatrice and any other program listed above.$\r$\n$\r$\nChoose Retry once they are closed, or Cancel to cancel the installation." \ + IDRETRY ocrp_close + !insertmacro CockLog "close-holders: declined by user after $HolderCloseRounds round(s)" + Return + ocrp_close: + IntOp $HolderCloseRounds $HolderCloseRounds + 1 + !insertmacro CockLog "close-holders: accepted (round $HolderCloseRounds), holders were [$HolderOutput]" + ; The close is a request, not a fact: AskCockatriceAppsToClose waits for the + ; instances it asked to exit, so a confirmation of Cockatrice's that is still up is + ; reported as still running instead of being measured as a successful close. + Call AskCockatriceAppsToClose + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLogListing "after-close-purge" "*.dll" + !insertmacro CockLogListing "after-close-plugins" "Plugins\*.*" + !insertmacro CockLog "close-holders: stale-runtime after retry=$StaleRuntimeAfterPurge" + ${If} $StaleRuntimeAfterPurge = 0 + DetailPrint "Files of the previous version were removed; continuing the installation." + Return + ${EndIf} + ; Still stuck: refresh the names, so the next prompt and the log describe what + ; survived the close and not what was there before it. + Call LogStaleRuntimeDiagnostics + ${If} $HolderFound != 1 + Return + ${EndIf} + Goto ocrp_prompt +FunctionEnd + +; Asks the running Cockatrice to close, and waits for it to exit. +; +; The set is the Restart Manager answer from LogProcessesUsingInstallDir: exactly the +; processes holding a file of the version being replaced. tools\installer-holder-probe +; closes that set by pid, which is why nothing here has to match an executable path or +; depend on powershell.exe. Only cockatrice.exe is asked: Oracle writes the card database +; in place and Servatrice has no window, so both stay the user's job, as the prompt says. +; +; Each instance is brought to the foreground before WM_CLOSE is posted to its main +; window. WM_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 purge 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. +Function AskCockatriceAppsToClose + InitPluginsDir + ; LogProcessesUsingInstallDir extracted it already; the check keeps a single copy of + ; the executable in the installer and still works if that order ever changes. + IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_close_probe_ready + File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" + ck_close_probe_ready: + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' + ; 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 + !insertmacro CockLog "close-holders: probe exit=$R3 [$R4]" + DetailPrint "Closing Cockatrice: $R4" +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 @@ -1786,13 +1978,21 @@ ${If} $IsUpgrade = 1 ${Else} StrCpy $StaleRuntimeAfterPurge 0 ${EndIf} +${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 + ; On /R the previous version is already uninstalled at this point, so an abort + ; leaves the user with no working Cockatrice. Offer to close and purge again, + ; repeating while the user keeps saying Yes; this updates + ; $StaleRuntimeAfterPurge with the last retry's verdict. + Call OfferToCloseAndRetryPurge +${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 @@ -1803,6 +2003,12 @@ ${If} $StaleRuntimeAfterPurge = 1 ${If} $HolderFound = 1 StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" ${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/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index 3098669cc..ab3bb3eff 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -4,7 +4,7 @@ # 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) +add_executable(installer-holder-probe close_holders.cpp holder_line.cpp main.cpp restart_manager_holders.cpp) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp new file mode 100644 index 000000000..508d9b3c1 --- /dev/null +++ b/tools/installer-holder-probe/close_holders.cpp @@ -0,0 +1,212 @@ +#include "close_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 +{ +constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe"; + +/// 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; + +bool isCockatriceImage(const std::wstring &imageName) +{ + return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; +} + +/// 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; +} + +struct WindowSearch +{ + DWORD processId = 0; + HWND topmost = nullptr; ///< first visible window in Z-order: a dialog when one is up + HWND main = nullptr; ///< first visible window without an owner: the one to close + HWND any = nullptr; ///< first window at all, used when nothing is visible +}; + +BOOL CALLBACK findProcessWindow(HWND window, LPARAM parameter) +{ + auto &search = *reinterpret_cast(parameter); + DWORD processId = 0; + GetWindowThreadProcessId(window, &processId); + if (processId != search.processId) { + return TRUE; + } + if (search.any == nullptr) { + search.any = window; + } + // A tray icon leaves the main window hidden, so a window that is not visible is still + // kept as a fallback. EnumWindows walks the Z-order top down, so the first visible + // window is the one the user would see in front. + if (IsWindowVisible(window) != FALSE) { + if (search.topmost == nullptr) { + search.topmost = window; + } + if (search.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + search.main = window; + } + } + return TRUE; +} + +/// 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 + +CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +{ + CloseHoldersResult result; + std::vector lines; + std::vector asked; + std::vector askedLines; + + 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(findProcessWindow, reinterpret_cast(&search)); + if (search.any == nullptr) { + lines.push_back(processLabel(holder) + L" has no window to close, left to you"); + continue; + } + + const HWND target = + search.main != nullptr ? search.main : (search.topmost != nullptr ? search.topmost : search.any); + const HWND toRaise = search.topmost != nullptr ? search.topmost : target; + std::wstring line = processLabel(holder); + line += bringForward(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; + if (PostMessageW(target, WM_CLOSE, 0, 0) == FALSE) { + // 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; + } + 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); + } + 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; + lines[askedLines[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 : asked) { + CloseHandle(process); + } + + if (lines.empty()) { + result.report = L"nothing is holding the files"; + return result; + } + for (const std::wstring &line : lines) { + if (!result.report.empty()) { + result.report += L"; "; + } + result.report += line; + } + if (result.report.size() > REPORT_CHAR_MAX) { + result.report.erase(REPORT_CHAR_MAX); + result.report += L"..."; + } + 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..427da5ea6 --- /dev/null +++ b/tools/installer-holder-probe/close_holders.h @@ -0,0 +1,46 @@ +#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 and in the second prompt. + */ +struct CloseHoldersResult +{ + unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to + std::wstring report; ///< one line, narrowed to ASCII when written +}; + +/** + * @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. + * + * Two things happen per instance. Its top-level window is restored and brought to the + * foreground, because Cockatrice may answer the close with a confirmation of its own that + * would otherwise open behind the installer's prompt, and then WM_CLOSE is posted to the + * main window, the same request as clicking its close button. Stopping the process is + * never attempted. + * + * 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. + * + * @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 + */ +CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); + +#endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 3b5bb5d22..269d22821 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -1,3 +1,4 @@ +#include "close_holders.h" #include "holder_line.h" #include "restart_manager_holders.h" @@ -12,8 +13,16 @@ namespace /// trailing backslash then never has to survive NSIS quoting or a command line. constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; +/// 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; + /// 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. enum ExitCode { EXIT_NO_HOLDERS = 0, @@ -22,6 +31,20 @@ enum ExitCode EXIT_PROBE_FAILED = 3, }; +/// The probe has two modes, selected by the first argument: +/// +/// (none) name what holds the install directory, one line, for the abort +/// dialog and the 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 +/// +/// Both answer from the same Restart Manager query, so the close can only ever reach a +/// process that is holding a file of the version being replaced. +bool closeModeRequested(int argumentCount, char **arguments) +{ + return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; +} + /// 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 @@ -54,7 +77,7 @@ std::wstring installDirFromEnvironment() } } // namespace -int main() +int main(int argumentCount, char **arguments) { const std::wstring installDir = installDirFromEnvironment(); if (installDir.empty()) { @@ -68,11 +91,23 @@ int main() writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); return EXIT_PROBE_FAILED; } + + const bool closeMode = closeModeRequested(argumentCount, arguments); if (holders.empty()) { - // No output: the installer words the "nothing is holding them" case, and it - // owns the message either way. + // No output in the naming mode: the installer words the "nothing is holding them" + // case, and it owns the message either way. The close mode says it itself, because + // its line is logged verbatim next to what it was asked to do. + if (closeMode) { + writeLine(L"nothing is holding the files"); + } return EXIT_NO_HOLDERS; } - writeLine(formatHolders(holders)); - return EXIT_HOLDERS_FOUND; + if (!closeMode) { + writeLine(formatHolders(holders)); + return EXIT_HOLDERS_FOUND; + } + + const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS); + writeLine(result.report); + return result.askedToClose > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; } From d42b9607c54348f6ade066ef881470b282a67618 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:25:10 +0100 Subject: [PATCH 127/247] [Installer] Check for running instances with the Restart Manager probe (#7671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Check for running instances with the Restart Manager probe The running-instance guard was built on powershell.exe started from the 32-bit NSIS stub, which is the 32-bit Windows PowerShell: its Get-Process cannot read the path of a 64-bit process, so a running 64-bit Cockatrice was reported as not running, the update went ahead, and the purge then failed on the DLLs that instance still had mapped. Use tools/installer-holder-probe instead - the same Restart Manager lookup the abort path already had - before anything is replaced and in the uninstaller as well. It is bitness-agnostic, so the Sysnative and WMI fallbacks are gone. The guard looks only at the payload, asks Cockatrice to close once by itself (WM_CLOSE, the same request as the window's close button) and then offers close again / retry after closing by hand / cancel for whatever the close cannot reach - Oracle writes the card database in place, Servatrice has no window - without force-stopping anything. A failed purge offers the same close-and-retry, tries one last purge when nobody is named, and every dialog shows in every mode: a silent reinstall stays silent until something goes wrong. * [Installer] Name processes running from the install directory The running-instance guard answers from the Restart Manager, which reports who has a file open. 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 mapping is not reliably reported. That is the state the probe was added for - 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 is blind to exactly that instance, so it passes, the purge fails, the holder lookup after it names nobody either, and the install ends in a warning that no running program was found: no close is attempted and no retry is offered. A manual install with another instance open never asks it to close at all. Name every process whose image lives in the directory being replaced as well, with the same bitness-safe QueryFullProcessImageNameW the probe already uses to label its holders. The image lookup is listed first - it is the instance the user is asked to close - and the Restart Manager entries follow with the pids already named dropped, so the close still reaches everything that holds a file. A Restart Manager call that fails only makes the probe unable to answer when the image lookup named nobody either. Ask instead of dead-ending when the probe cannot answer at all: Retry checks again, Ignore carries on, Abort cancels, in the installer and the uninstaller alike, and the abort dialog says the check failed instead of claiming no running program was found. The Windows CI probe check covers the new lookup with a process started from the directory that holds no file open. * [Installer] Offer to wait again when the launcher has not exited A client that is still closing after 60 s is usually not hung: it is showing its own "quit anyway?" confirmation for an unsaved deck, an open game or a card database update, and the user is the one who has to answer it. The installer answered that with a plain warning and stopped, so the only way forward was to close Cockatrice by hand and run the update again - which on an in-app update means finding the downloaded installer. Ask instead: Retry brings the client to the front, asks it to close again (WM_CLOSE, nothing forced) and keeps waiting on its handle, Cancel stops as before. There is no "install anyway" answer because a live process has its image and Qt DLLs mapped, so writing the payload over them is the broken install this wait exists to prevent - and the guard that runs right after names the same process by its image path and stops there, so ignoring would only move the same question a moment later. * [Installer] Ask about a launcher that has not exited after ten seconds A minute of silence is a long time to sit in front of an installer that has no window yet, and the case it is waiting for is not a slow flush: a client that started an update exits in well under a second, so a minute means it is almost always showing its own "quit anyway?" confirmation with nobody looking at it. Wait ten seconds for the first question instead, and the full grace for the ones after it. The normal path is untouched - WaitForSingleObject returns as soon as the handle signals, so a client that exits in 200 ms still costs 200 ms - and answering the first question once does not turn into a stream of prompts while the user is in Cockatrice's window. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 22 +- cmake/NSIS.template.in | 1207 +++++++---------- tools/installer-holder-probe/CMakeLists.txt | 13 +- tools/installer-holder-probe/main.cpp | 40 +- .../restart_manager_holders.cpp | 106 +- .../restart_manager_holders.h | 41 +- 6 files changed, 721 insertions(+), 708 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index d60683b6e..e85e2b5ba 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -515,7 +515,27 @@ try { 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. + $copy = Join-Path $dir "fake-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 "fake-cockatrice\.exe \(pid\s+$($running.Id)") { + throw "the lookup did not name the process running from the directory: $($byImage.Output)" + } + Write-Host "holder lookup named pid $PID for a locked file, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 6ce765a35..d54111a84 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -12,7 +12,6 @@ Var NormalDestDir Var PortableDestDir Var PortableMode Var ReinstallMode -Var AllowForceClose Var GuardDir Var OldUninstallString Var OldInstallDir @@ -38,11 +37,19 @@ Var AbortReason Var HolderExit Var HolderOutput Var HolderFound -; OfferToCloseAndRetryPurge counts the accepted close-and-retry rounds, so the -; prompt can stop repeating an offer that provably did not help (see there). -Var HolderCloseRounds +; 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 +; Whether the guard already asked Cockatrice to close once (see GuardAppsNotRunning) +Var GuardCloseTried 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 Var InstallLogPath Var LogHandle Var LogMessage @@ -107,7 +114,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 == "" @@ -119,6 +129,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 @@ -231,10 +255,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 "" @@ -272,18 +296,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 ever force-stopped, 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. @@ -307,6 +329,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 @@ -685,230 +715,196 @@ 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 +; process whose image lives in the directory being replaced. ; -; 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. - -; 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} -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} -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. - ; - ; The window is activated first, because WM_CLOSE is answered, not obeyed: - ; Cockatrice replies with a confirmation of its own while a card database - ; update is running, a game is open or a deck is unsaved. Without the - ; activation that dialog opens behind the installer's own prompt, where it - ; looks like the close did nothing and the second WM_CLOSE is delivered to a - ; main window its own modal dialog has disabled, which is ignored. Failing to - ; activate is harmless and swallowed: the close request still goes out. - 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 { try { $$null = (New-Object -ComObject WScript.Shell).AppActivate($$_.Id) } catch { }; $$null = $$_.CloseMainWindow() }"' - Pop $R3 -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. - 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 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 - 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." - 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." - 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 - ${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. +; 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. ; -; 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 +; 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 anywhere: Cockatrice may be holding an unsaved deck +; or showing a "really leave this game?" confirmation, Oracle writes the card +; database in place and killing it mid-write would corrupt it, and Servatrice +; has no window to ask. A close is a request (WM_CLOSE), made automatically once +; because that is what the user started the installer to do, and again whenever +; they ask for it from the prompt; when it is not answered the user is told which +; program is in the way instead of having it killed. + +; 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 + +; 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 nothing runs from the directory, 1 = the +; line names what does, anything else = the probe could not answer (2 = no +; directory was given, 3 = the Restart Manager call failed and no process 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 + +; 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 processes running from the directory. tools\installer-holder-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, so both stay the user's job, as the prompt says. +; +; Each instance is brought to the foreground before WM_CLOSE is posted to its main window. +; WM_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: the application decides whether to quit, and one that does not answer +; is left to the user instead of being killed. +Function ${prefix}CloseHolders + Call ${prefix}ExtractHolderProbe + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; 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 +!macroend + +!insertmacro CockHolderProbeFunctions "" install +!insertmacro CockHolderProbeFunctions "un." uninstall + +; usage: set $GuardDir to the directory to guard, then call this. +; +; Asks Cockatrice to close once, then prompts until the payload is free. 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 Cockatrice may still be waiting for an answer in +; its own confirmation. It offers one more close (Yes), a plain re-check for after the +; user closed them by hand (No), and Cancel. +; +; 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 $GuardCloseTried 0 + 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} $GuardExit == "1" + !insertmacro CockLog "guard: still in use by $GuardOutput" + ; Close it for the user first. The probe knows exactly which cockatrice.exe holds + ; these files, and asking it to close is the same request as clicking the window's + ; close button - the application still answers its own "really quit?" prompt, so + ; nothing is lost. One attempt per guard call keeps the common case (the client was + ; still shutting down) from turning into a question, without hiding the case where + ; the close did not help: the pass after it names what is still holding the files. + ${If} $GuardCloseTried = 0 + StrCpy $GuardCloseTried 1 + !insertmacro CockLog "guard: asking cockatrice to close, then checking again" + Call CloseHolders + Goto guard_check + ${EndIf} + DetailPrint "The install directory is in use by: $GuardOutput" + ; Three answers, and the close is one of them: the automatic attempt above ran + ; before this prompt and could not have answered Cockatrice's own confirmation, so a + ; second close is often exactly what is needed once the user has dealt with that + ; window. NSIS does not let a MessageBox relabel its buttons, so the text says what + ; Yes, No and Cancel do. + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ + IDYES guard_close IDNO guard_check + !insertmacro CockLog "guard: cancelled by user" 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." + "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." Call AbortInstall + ; Only reached through Yes: AbortInstall does not return. + guard_close: + !insertmacro CockLog "guard: closing cockatrice on request, then checking again" + Call CloseHolders + Goto guard_check ${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 nothing runs from the directory, or it 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), 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 check whether Cockatrice is still running: $GuardOutput" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "This installer could not check 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 check 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." + Call AbortInstall FunctionEnd Function EnsureAppsNotRunning @@ -919,67 +915,55 @@ 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} + 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. + ; 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 @@ -989,16 +973,15 @@ FunctionEnd ; ; 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. Function WaitForLauncherToExit StrCpy $LauncherExited 0 !insertmacro CockLog "wait-launcher: pid='$WaitPid'" @@ -1014,6 +997,17 @@ Function WaitForLauncherToExit ; $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 Retry below, so a launcher that exited in the meantime answers + ; "already exited" instead of being waited on again. + ; + ; 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' ; OpenProcess returns a null handle for a process that is already gone. The @@ -1033,17 +1027,39 @@ Function WaitForLauncherToExit 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::WaitForSingleObject(p R0, i $WaitSlice) i.R1' System::Call 'kernel32::CloseHandle(p R0)' ${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." + !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1)" + DetailPrint "The application that started this installer did not exit in time." + ; Two answers, and "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 - and the guard that runs next names the + ; same process by its image path and stops there anyway, so an "ignore" would + ; only put the same question a moment later, after a close attempt nobody asked + ; for. Waiting again is what helps: the client is most likely showing its own + ; "quit anyway?" confirmation, and that window is the thing to answer. + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "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. Answer that window - choose Retry to bring it to the front, ask it to close again and keep waiting. If it does not answer at all, close it from Task Manager first.$\r$\n$\r$\nChoose Retry to wait again, or Cancel to cancel the installation." \ + IDRETRY 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 Retry: AbortInstall does not return. + wait_launcher_retry: + ; 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. + StrCpy $WaitSlice 60000 + ; The same request the window's close button makes, and the same close the guard + ; would send a moment later. It brings the client forward as well, which is the + ; part that helps when the confirmation is behind something else. Nothing is + ; force-stopped: a client that is waiting for an answer keeps the last word. + StrCpy $GuardDir $INSTDIR + Call CloseHolders + Goto wait_launcher_open ${EndIf} !insertmacro CockLog "wait-launcher: launcher exited" DetailPrint "The application that started this installer has exited." @@ -1068,57 +1084,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 @@ -1321,16 +1286,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 makes -; a zero code mean "the open worked". That is all it means: Windows lets a DLL a -; running process has mapped be opened for DELETE, and DeleteFile still refuses -; it until the image is unloaded, so a zero code is not evidence that the -; failure was transient - the holders line is what names a running process. 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 != "" @@ -1347,22 +1314,52 @@ Function LogStaleRuntimeDiagnostics ${Else} Pop $R2 ${If} $R2 = 0 - ; A valid handle: close it. The open 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 (the purge clears it before every attempt - ; now, so a survivor that is read-only had it set again). 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 read-only, which stops Delete" - ${Else} - StrCpy $R0 "a delete-access open now succeeds, but a DLL a running process has mapped still refuses Delete - see holders" + StrCpy $R0 "$R0; it is read-only again" ${EndIf} ${EndIf} ${Else} @@ -1380,45 +1377,34 @@ 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 process running from the directory, 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; then the per-file codes logged +; above are the whole story, and the probe says so by naming nothing rather than by +; failing. ; -; 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 @@ -1426,85 +1412,69 @@ 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 program is running from the directory" ${Else} StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" ${EndIf} !insertmacro CockLog "holders: $HolderOutput" FunctionEnd -; 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 - ask, close what this -; installer owns, purge again, and retry while the user keeps asking. Sets -; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts -; when the files survive. +; 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 - ask, close what this installer owns, purge +; again, and retry while the user keeps asking. Sets $StaleRuntimeAfterPurge to the last +; purge's verdict, so the caller still aborts when the files survive. ; -; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the -; window's close button) and 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, exactly as the -; running-instance guard treats it. The instance is brought forward first, so that -; confirmation is on screen rather than behind this installer's prompt. Oracle and -; Servatrice have no window to send a close to at all, and nothing here is -; force-stopped: Oracle writes the card database in place and killing it mid-write -; would corrupt it. Everything the probe named that this installer cannot close - -; those two included - is left to the user, whom the prompt names it to. +; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the window's close +; button) and 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. Oracle and Servatrice have no +; window to send a close to at all, and nothing here is force-stopped: Oracle writes the +; card database in place and killing it mid-write would corrupt it. Everything the probe +; named that this installer cannot close - those two included - is left to the user, whom +; the prompt names it to. ; ; 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. +; 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. ; -; No offer when nobody is at the keyboard (a /S install without /R, e.g. a package -; manager): a dialog there would block forever. /R is the in-app updater, whose -; client has already quit, so the user is there - the same reasoning AbortInstall -; uses for its message box. No offer either when the probe named nobody: there is -; nothing to close, and the abort's per-file diagnostics already record what -; Windows said. +; 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 +; close, and the abort's per-file diagnostics already record what Windows said. ; -; The first round offers the close; every later one stops offering it and says -; what to do instead. Repeating the same question is what made an accepted close -; look like a Yes that was ignored: when Cockatrice was blocked on its own -; confirmation, the second Yes was delivered to a window that dialog had disabled, -; so nothing closed, the holders were unchanged, and the identical prompt came -; back with no hint of what had happened. -Function OfferToCloseAndRetryPurge - ${If} $ReinstallMode = 0 - ${AndIf} ${Silent} - !insertmacro CockLog "close-holders: unattended install, not offering to close holders" - Return - ${EndIf} +; Every round offers all three answers: Yes closes Cockatrice and purges again, No purges +; again without asking it to close (for when the user closed things by hand), and Cancel +; gives up. The close stays on offer after one that did nothing because the usual reason is +; Cockatrice's own confirmation still waiting for an answer - the probe waits for that, the +; user can answer it in the application's window, and the next round can then collect the +; process the earlier attempt could not. Each round re-reads the holders, so the prompt +; names what survived the last attempt rather than repeating the first listing. +Function RecoverFromHolders ${If} $HolderFound != 1 - !insertmacro CockLog "close-holders: no holder was named, nothing to offer" + !insertmacro CockLog "recover-holders: no holder was named, nothing to offer" Return ${EndIf} - StrCpy $HolderCloseRounds 0 - ocrp_prompt: - ${If} $HolderCloseRounds = 0 - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nThe installer can close Cockatrice for you. It may first ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose Yes to close Cockatrice and try again, or No to cancel the installation." \ - IDYES ocrp_close - !insertmacro CockLog "close-holders: declined by user" - Return - ${EndIf} - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nCockatrice may be waiting for an answer in its own window - it asks before quitting while a card database update is running, a game is open or a deck is unsaved. Switch to it and confirm, then close Oracle, Servatrice and any other program listed above.$\r$\n$\r$\nChoose Retry once they are closed, or Cancel to cancel the installation." \ - IDRETRY ocrp_close - !insertmacro CockLog "close-holders: declined by user after $HolderCloseRounds round(s)" + rfh_prompt: + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and try the removal again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose No to try again after closing the programs yourself, or Cancel to cancel the installation." \ + IDYES rfh_close IDNO rfh_retry + !insertmacro CockLog "recover-holders: declined by user" Return - ocrp_close: - IntOp $HolderCloseRounds $HolderCloseRounds + 1 - !insertmacro CockLog "close-holders: accepted (round $HolderCloseRounds), holders were [$HolderOutput]" - ; The close is a request, not a fact: AskCockatriceAppsToClose waits for the - ; instances it asked to exit, so a confirmation of Cockatrice's that is still up is - ; reported as still running instead of being measured as a successful close. - Call AskCockatriceAppsToClose + rfh_close: + !insertmacro CockLog "recover-holders: closing cockatrice on request, holders were [$HolderOutput]" + ; The close is a request, not a fact: CloseHolders waits for the instances it asked to + ; exit, so a confirmation of Cockatrice's that is still up is reported as still running + ; instead of being measured as a successful close. + StrCpy $GuardDir $INSTDIR + Call CloseHolders + rfh_retry: Call RetryDllPurge StrCpy $StaleRuntimeAfterPurge $R0 - !insertmacro CockLogListing "after-close-purge" "*.dll" - !insertmacro CockLogListing "after-close-plugins" "Plugins\*.*" - !insertmacro CockLog "close-holders: stale-runtime after retry=$StaleRuntimeAfterPurge" + !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 @@ -1515,179 +1485,12 @@ Function OfferToCloseAndRetryPurge ${If} $HolderFound != 1 Return ${EndIf} - Goto ocrp_prompt -FunctionEnd - -; Asks the running Cockatrice to close, and waits for it to exit. -; -; The set is the Restart Manager answer from LogProcessesUsingInstallDir: exactly the -; processes holding a file of the version being replaced. tools\installer-holder-probe -; closes that set by pid, which is why nothing here has to match an executable path or -; depend on powershell.exe. Only cockatrice.exe is asked: Oracle writes the card database -; in place and Servatrice has no window, so both stay the user's job, as the prompt says. -; -; Each instance is brought to the foreground before WM_CLOSE is posted to its main -; window. WM_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 purge 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. -Function AskCockatriceAppsToClose - InitPluginsDir - ; LogProcessesUsingInstallDir extracted it already; the check keeps a single copy of - ; the executable in the installer and still works if that order ever changes. - IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_close_probe_ready - File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" - ck_close_probe_ready: - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' - ; 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 - !insertmacro CockLog "close-holders: probe exit=$R3 [$R4]" - DetailPrint "Closing Cockatrice: $R4" -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 - 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 - 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 - ${EndIf} + Goto rfh_prompt 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 @@ -1695,29 +1498,54 @@ 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 $GuardCloseTried 0 + 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 Cockatrice to close once, then report + ; what the close could not reach. + ${If} $GuardCloseTried = 0 + StrCpy $GuardCloseTried 1 + !insertmacro CockLogUn "uninst-guard: asking cockatrice to close, then checking again" + Call un.CloseHolders + Goto un_guard_check + ${EndIf} + DetailPrint "The install directory is in use by: $GuardOutput" + ; Same three answers as the installer's guard; the automatic close above already + ; ran once before this prompt. + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ + IDYES un_guard_close IDNO un_guard_check + !insertmacro CockLogUn "uninst-guard: cancelled by user" + SetErrorLevel 1 + Abort + ; Only reached through Yes: Abort does not return. + un_guard_close: + !insertmacro CockLogUn "uninst-guard: closing cockatrice on request, then checking again" + Call un.CloseHolders + Goto un_guard_check + ${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 can simply be retried. + !insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput" + DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "This uninstaller could not check 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 @@ -1984,24 +1812,43 @@ ${If} $StaleRuntimeAfterPurge = 1 ; 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 - ; On /R the previous version is already uninstalled at this point, so an abort - ; leaves the user with no working Cockatrice. Offer to close and purge again, - ; repeating while the user keeps saying Yes; this updates - ; $StaleRuntimeAfterPurge with the last retry's verdict. - Call OfferToCloseAndRetryPurge + ${If} $HolderFound = 1 + ; On /R the previous version is already uninstalled at this point, so an abort + ; leaves the user with no working Cockatrice. Offer to close and purge again, + ; repeating while the user keeps saying Yes; this updates + ; $StaleRuntimeAfterPurge with the last retry's verdict. + Call RecoverFromHolders + ${Else} + ; Nothing is holding them. The three attempts in RetryDllPurge were two seconds + ; apart and all failed with no process to blame, which is the transient case a + ; virus scanner or a background indexer creates - or a permission problem that + ; will not clear. Give the delete one last chance before giving up: it costs a + ; second on a path that is about to abort anyway, and it keeps a lock that was + ; released in the meantime from turning into a failed upgrade. + !insertmacro CockLog "section: no holder named, one final purge attempt" + Sleep 1000 + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLog "section: final-purge stale-runtime=$StaleRuntimeAfterPurge" + ${EndIf} ${EndIf} ${If} $StaleRuntimeAfterPurge = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." - ; The in-app updater runs this installer silent and detached and never looks - ; at its exit code, so the user has to be told what happened. A /S install - ; without /R is unattended (a package manager, for instance) and a message - ; box there would block forever, so that case only reports through the log - ; and the exit code. AbortInstall owns that decision. + ; Say what is known and nothing more: the guard ran before anything was replaced, + ; so "most likely still running" is no longer a fair guess and would send the user + ; to close programs that are already closed. StrCpy $AbortReason \ - "The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + "The installation could not remove all files of a previous version of Cockatrice, and was cancelled rather than leave a mismatched installation behind." ${If} $HolderFound = 1 - StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" + 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. diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index ab3bb3eff..e4def8c39 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -1,9 +1,10 @@ -# 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. +# 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 main.cpp restart_manager_holders.cpp) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 269d22821..db46035d3 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -2,6 +2,7 @@ #include "holder_line.h" #include "restart_manager_holders.h" +#include #include #include #include @@ -22,7 +23,9 @@ constexpr unsigned long CLOSE_WAIT_MILLISECONDS = 30000; /// 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. +/// to the user, which is what the report line next to the code spells out. 3 is only for +/// a lookup that failed and named nobody: one that failed but still named a process +/// running from the directory is answered with that process. enum ExitCode { EXIT_NO_HOLDERS = 0, @@ -33,13 +36,17 @@ enum ExitCode /// The probe has two modes, selected by the first argument: /// -/// (none) name what holds the install directory, one line, for the abort -/// dialog and the log +/// (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 /// -/// Both answer from the same Restart Manager query, so the close can only ever reach a -/// process that is holding a file of the version being replaced. +/// Both answer from two lookups that cover each other's blind spot: the Restart Manager +/// names whoever has a payload file open, and every process running from the directory 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. Neither lookup alone is the guard's authority: an instance the file +/// handles do not name is exactly the case that left a half-replaced install behind. bool closeModeRequested(int argumentCount, char **arguments) { return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; @@ -85,9 +92,26 @@ int main(int argumentCount, char **arguments) return EXIT_NOT_CONFIGURED; } - std::vector holders; - const unsigned long error = findProcessesUsingFiles(collectProbeFiles(installDir), holders); - if (error != ERROR_SUCCESS) { + // Processes 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. + std::vector holders = findProcessesRunningFrom(installDir); + std::vector fileHolders; + const unsigned long error = findProcessesUsingFiles(collectPayloadFiles(installDir), fileHolders); + for (const Holder &holder : fileHolders) { + 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); + } + } + + // A failed lookup only makes the probe unable to answer when it also named nobody: with + // a process running from the directory the installer still knows what to close, and + // that is worth more to the guard than the code of the query that missed it. + if (error != ERROR_SUCCESS && holders.empty()) { writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); return EXIT_PROBE_FAILED; } diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index 39f6a0049..c00e5da8c 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,9 @@ 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; +/// The payload files the installer replaces by name; the DLLs are matched by mask below. +constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; + void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -76,6 +81,30 @@ std::wstring fileNameOf(const std::wstring &path) return separator == std::wstring::npos ? path : path.substr(separator + 1); } +/// @p directory with exactly one trailing separator, so that a sibling whose name merely +/// begins with the same letters ("Cockatrice-old" next to "Cockatrice") cannot pass for a +/// subdirectory of it. +std::wstring directoryPrefixOf(const std::wstring &directory) +{ + std::wstring prefix = directory; + while (!prefix.empty() && (prefix.back() == L'\\' || prefix.back() == L'/')) { + prefix.pop_back(); + } + prefix += L'\\'; + return prefix; +} + +/// Whether @p path lies inside @p prefix, which is a directory with a trailing separator. +/// Ordinal and case-insensitive, as Windows compares paths. +bool isUnderDirectory(const std::wstring &path, const std::wstring &prefix) +{ + if (path.size() < prefix.size()) { + return false; + } + const int length = static_cast(prefix.size()); + return CompareStringOrdinal(path.c_str(), length, prefix.c_str(), length, TRUE) == CSTR_EQUAL; +} + /// Copies a fixed-size, NUL-terminated WCHAR array the API filled in. template std::wstring fixedString(const wchar_t (&text)[N]) { @@ -96,13 +125,39 @@ 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); + for (const wchar_t *name : PAYLOAD_FILES) { + 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 +213,44 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st RmEndSession(session); return error; } + +std::vector findProcessesRunningFrom(const std::wstring &installDir) +{ + std::vector holders; + if (installDir.empty()) { + return holders; + } + + const std::wstring prefix = directoryPrefixOf(installDir); + // Never the probe itself: it runs from the directory being replaced when the + // uninstaller asks in place or the installer was copied next to the payload, and + // naming it would ask the user to close the process that is doing the asking. + const DWORD self = GetCurrentProcessId(); + + 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 (entry.th32ProcessID == self) { + 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); + if (!isUnderDirectory(imagePath, prefix)) { + continue; + } + Holder holder; + holder.processId = entry.th32ProcessID; + holder.imageName = fileNameOf(imagePath); + 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..6dcd91108 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -7,17 +7,19 @@ #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 +34,27 @@ std::vector collectProbeFiles(const std::wstring &installDir); */ unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); +/** + * @brief Lists every process whose image lives in @p installDir, whether or not it has a + * payload file open. + * + * 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. + * + * 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, so a process that merely shares a name (an Oracle + * database server next to a Cockatrice install) is never named. + * + * @param installDir the directory being replaced, as a full path. + * @return one entry per process running from it, in snapshot order, without the probe + * itself; empty when the directory is empty or no process runs from it. + */ +std::vector findProcessesRunningFrom(const std::wstring &installDir); + #endif From aa42cfbccc093549b35538d9220241d9e89c4871 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:12:37 +0100 Subject: [PATCH 128/247] [VisualDeckEditor] Create menus before central frame to prevent Windows crash in setParent_sys (#7673) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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..964babcae 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 @@ -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. */ From 92d082e7353a9b0f2bc4fc14f2cb1cf6493a31aa Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:14:52 +0100 Subject: [PATCH 129/247] [VisualDeckStorage] Guard against widget deletion during tag edit dialog (#7674) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../deck_preview/deck_preview_deck_tags_display_widget.cpp | 6 ++++++ 1 file changed, 6 insertions(+) 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..1a06c0b18 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 @@ -12,6 +12,7 @@ #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); From 30d3afc7b4c8e8ca2aae12ec53f513d8d080a590 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:19:49 +0100 Subject: [PATCH 130/247] [Updater] Close a running Cockatrice when the installer asks (#7672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Updater] Refuse a close that arrives while one is already asking MainWindow::closeEvent() had a re-entrancy guard that returned without touching the event. A QCloseEvent is accepted unless something ignores it, so a second close request - an installer replacing our files posts one every 30 s - accepted the close while the first one was still blocked on its own confirmation. close() then hid this window, hid the confirmation dialog with it, and left the process running inside the nested loop that dialog had opened: no window, no way to answer it, and every later close request accepted into the same dead end. That is what an update looked like when it could not close a running client, and it is why the installer could name the process, bring it forward and ask it to close for minutes without anything happening. Track how far the shutdown has got instead. While a confirmation is up, a close from anywhere else is refused, which leaves the question in front of the user; once the shutdown has been accepted, a duplicate close (Qt delivers the event twice on some platforms) is accepted again so it cannot cancel the quit. * [Installer] Leave an instance that is asking a question alone An instance that answered a close by hiding rather than quitting - the state the client used to get into - has no visible window left, and the confirmation it is waiting on is hidden with the window that owns it. The close probe only ever looked at visible windows, so it restored the main window, posted WM_CLOSE into the same dead end, and reported "still running" without saying why. Show every window the instance left hidden before choosing the one to close, and skip the close when the window it would reach is disabled: a modal dialog of the instance's own is up, and posting the close then only hides the question again. Name what is left of the window in the report as well - gone, still open, hidden again, and whether the thread answers at all - so the log tells a refused close from one that was never delivered. * [Installer] Offer to end a Cockatrice that will not close A close is a request, and an instance that does not answer it - waiting on a question nobody is looking at, or stuck where a close cannot reach - leaves the user with no way forward: the files cannot be replaced while it runs, and clicking Yes again only repeats the request. After a second close that changed nothing, offer the only thing that can free the files: end it. The prompt says what it costs - an unsaved deck, a running game or a setting it has not written yet - the default button is the safe one, and it is a separate answer rather than a relabelled "Yes", so it is not reached by clicking through the close prompt. Only cockatrice.exe is ever ended: Oracle writes the card database in place and Servatrice is a service, so both are named and left to the user, exactly as they are for the close. The probe gains an --end-holders mode for it, and the uninstaller's guard gets the same offer. * [Installer] Never name the process that is doing the asking The old uninstaller is started with _?= so it runs in place, from the very directory it is emptying, and an installer can be downloaded into the folder it installs to. Both are then processes running from the directory, so the probe named them - and the guard asked the user to close the program that was asking. That question cannot be answered: a close and the end offer both skip anything that is not cockatrice.exe, so the guard repeated itself until the update or the uninstall was cancelled. The probe and the processes that started it are now never named, by either lookup. 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. The Windows check held its locked file in the script that starts the probe, so that holder is excluded by design now and the check read it as "nothing found". It holds the file from a process of its own instead, and asserts the exclusion itself: a file that only the asking process holds comes back as nothing. * [Installer] Restore only the windows a user could answer Showing every hidden window an instance owns put Qt's own bookkeeping windows on screen: the tray icon keeps one to receive the TaskbarCreated message, styled WS_OVERLAPPED and titled QTrayIconMessageWindow. Restored, it is an empty white rectangle in the taskbar with no title bar to close it - it has no window procedure that would hide it again, and the process is busy in the very loop the restore is meant to break, so it stayed until the process was force-ended. Only framed, non-empty windows are restored and considered for the close now. That also keeps the close off Qt's tray window, which answers WM_CLOSE by asking the application to terminate - a quit that skips the confirmation the close is supposed to reach. * [Installer] Uninstall the previous version before installing over it A regular install over an existing one asked "a previous version must be uninstalled", then ran the old uninstaller interactively. That uninstaller put its confirmation page behind the installer window, where it could not be answered, and it only removed the installation after the installer had already written the new one over it. Run the previous version's uninstaller silently instead. The installer has already asked the question the user answered, so a second, hidden confirmation is pure risk: with /S there is no window to be behind, and ExecWait is then waiting for something that cannot outlive it. QuietUninstallString is used when the old installation has one, otherwise /S is appended. ExecWait only waits while the uninstaller runs in place: one that did not get a usable _?= copies itself to %TEMP% and returns at once, while the copy keeps deleting - which is how an uninstall can finish after the install it was supposed to precede. Log the exit code and wait for the old cockatrice.exe to disappear before restoring user data or writing anything, and abort with a message instead of installing into a directory that is still being emptied. Cancelling the "must be uninstalled" prompt now quits the installer; it used to skip the page and install over the old version anyway. * [Installer] Do not show windows the application has hidden The guard restored every hidden window an instance owned before asking it to close, so that a confirmation hidden together with its window would come back. Windows does as it is told and shows them; the application does not, because Qt still believes they are hidden and never paints them. What the user gets is a blank white rectangle, on screen and on the taskbar, that answers nothing - and the question it was meant to bring back is not answerable in it either. An instance with no window a user could answer is named and left to the user instead, and the prompt says what to do about it: open it from the notification area. Ending it stays the last resort, offered once a close has been asked for twice. The abort after a failed uninstall now says what it saw - the old version is still there after its uninstaller ran - instead of promising a removal that is not happening, which is the state a cancelled old uninstaller leaves behind. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 56 ++++- cmake/NSIS.template.in | 170 +++++++++++-- cockatrice/src/interface/window_main.cpp | 32 ++- cockatrice/src/interface/window_main.h | 12 +- .../installer-holder-probe/close_holders.cpp | 232 +++++++++++++++--- tools/installer-holder-probe/close_holders.h | 48 +++- tools/installer-holder-probe/main.cpp | 53 +++- .../restart_manager_holders.cpp | 68 ++++- .../restart_manager_holders.h | 25 +- 9 files changed, 606 insertions(+), 90 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index e85e2b5ba..1a30a0fd3 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,17 +504,61 @@ 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)" } $empty = Join-Path $dir "empty" New-Item -ItemType Directory -Force $empty | Out-Null $nothing = Invoke-Probe $empty @@ -535,7 +583,7 @@ try { if ($byImage.Output -notmatch "fake-cockatrice\.exe \(pid\s+$($running.Id)") { throw "the lookup did not name the process running from the directory: $($byImage.Output)" } - Write-Host "holder lookup named pid $PID for a locked file, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" + Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index d54111a84..72d5287fc 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -42,7 +42,9 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; Whether the guard already asked Cockatrice to close once (see GuardAppsNotRunning) +; How many closes the guard has asked for: 0 before the automatic one, 1 after it, and one +; more for every "Yes" at the prompt. From the second one on, the prompt offers to end the +; instance instead (see GuardAppsNotRunning). Var GuardCloseTried Var WaitPid Var LauncherExited @@ -391,7 +393,9 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall -ExecWait '$R0 _?=$OldInstallDir' +ExecWait '$R0 _?=$OldInstallDir' $R5 +!insertmacro CockLog "uninstall (32-bit): exit=$R5" +Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -406,7 +410,9 @@ ${If} ${RunningX64} StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$OldInstallDir' + ExecWait '$R0 _?=$OldInstallDir' $R5 + !insertmacro CockLog "uninstall (64-bit): exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: @@ -414,6 +420,44 @@ ${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 +; installing over a directory that is still being emptied. +Function WaitForOldPayloadToDisappear +${If} $OldInstallDir == "" + 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" + StrCpy $AbortReason \ + "The previous version in $OldInstallDir is still there after its uninstaller ran.$\r$\n$\r$\nIf that uninstaller was cancelled or reported an error, close anything still using those files and run this installer again." + Call AbortInstall +${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} +FunctionEnd + ; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall ; ; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR". @@ -748,13 +792,15 @@ FunctionEnd ; the image lookup matches on the directory a process runs from, so it does not ; widen that set. ; -; Nothing is force-stopped anywhere: Cockatrice may be holding an unsaved deck -; or showing a "really leave this game?" confirmation, Oracle writes the card -; database in place and killing it mid-write would corrupt it, and Servatrice +; 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 killing it mid-write would corrupt it, and Servatrice ; has no window to ask. A close is a request (WM_CLOSE), made automatically once ; because that is what the user started the installer to do, and again whenever -; they ask for it from the prompt; when it is not answered the user is told which -; program is in the way instead of having it killed. +; they ask for it from the prompt. When it is not answered the user is told which +; program is in the way, and once a close has been asked for twice without freeing +; the files they are offered to end Cockatrice themselves - the last resort, with +; what it costs spelled out, and never applied to Oracle or Servatrice. ; 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 @@ -806,8 +852,9 @@ FunctionEnd ; 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: the application decides whether to quit, and one that does not answer -; is left to the user instead of being killed. +; Never a force-close here: the application decides whether to quit. Ending it is a separate +; answer the prompt offers once a close has been asked for twice and ignored, spelled out +; with what it costs - see EndHolders - and it is only ever applied to cockatrice.exe. Function ${prefix}CloseHolders Call ${prefix}ExtractHolderProbe System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' @@ -822,6 +869,27 @@ Function ${prefix}CloseHolders Call ${prefix}CockLogWrite DetailPrint "Closing Cockatrice: $R4" FunctionEnd + +; usage: set $GuardDir to the directory being replaced, then call this. Ends the running +; Cockatrice 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. The probe +; ends cockatrice.exe and nothing else - Oracle writes the card database in place and +; Servatrice is a service, so both stay the user's job, exactly as they do for the close. +Function ${prefix}EndHolders + Call ${prefix}ExtractHolderProbe + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; 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: $R4" +FunctionEnd !macroend !insertmacro CockHolderProbeFunctions "" install @@ -835,7 +903,8 @@ FunctionEnd ; remains when the close cannot reach everything: Oracle writes the card database in ; place, Servatrice has no window, and Cockatrice may still be waiting for an answer in ; its own confirmation. It offers one more close (Yes), a plain re-check for after the -; user closed them by hand (No), and Cancel. +; user closed them by hand (No), and Cancel. Once a close has been asked for twice without +; freeing the files, it offers to end Cockatrice instead - 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 @@ -867,13 +936,35 @@ Function GuardAppsNotRunning Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" + ; A close that has been asked for twice and changed nothing is where asking again + ; stops being a plan: the instance is either waiting on a question the user has not + ; answered, or stuck where a close cannot reach it. Offer to end it - the only thing + ; left that can free the files - with the cost in the text, because a client that is + ; still running may have an unsaved deck, a game or a settings write in flight. + ${If} $GuardCloseTried >= 2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ + IDYES guard_end IDNO guard_close + !insertmacro CockLog "guard: cancelled by user" + StrCpy $AbortReason \ + "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." + Call AbortInstall + ; Only reached through Yes: AbortInstall does not return. + guard_end: + !insertmacro CockLog "guard: ending cockatrice on request, then checking again" + Call EndHolders + ; Back to the ordinary prompt afterwards: Oracle and Servatrice are never ended, + ; so what is left may still have to be closed by hand. + StrCpy $GuardCloseTried 1 + Goto guard_check + ${EndIf} ; Three answers, and the close is one of them: the automatic attempt above ran ; before this prompt and could not have answered Cockatrice's own confirmation, so a ; second close is often exactly what is needed once the user has dealt with that ; window. NSIS does not let a MessageBox relabel its buttons, so the text says what ; Yes, No and Cancel do. MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ IDYES guard_close IDNO guard_check !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ @@ -881,6 +972,7 @@ Function GuardAppsNotRunning Call AbortInstall ; Only reached through Yes: AbortInstall does not return. guard_close: + IntOp $GuardCloseTried $GuardCloseTried + 1 !insertmacro CockLog "guard: closing cockatrice on request, then checking again" Call CloseHolders Goto guard_check @@ -1517,16 +1609,33 @@ Function un.GuardAppsNotRunning Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" + ; Same escalation as in the installer's guard: after a second close that changed + ; nothing, offer to end the instance, with what it costs in the text. + ${If} $GuardCloseTried >= 2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ + IDYES un_guard_end IDNO un_guard_close + !insertmacro CockLogUn "uninst-guard: cancelled by user" + SetErrorLevel 1 + Abort + ; Only reached through Yes: Abort does not return. + un_guard_end: + !insertmacro CockLogUn "uninst-guard: ending cockatrice on request, then checking again" + Call un.EndHolders + StrCpy $GuardCloseTried 1 + Goto un_guard_check + ${EndIf} ; Same three answers as the installer's guard; the automatic close above already ; ran once before this prompt. MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ IDYES un_guard_close IDNO un_guard_check !insertmacro CockLogUn "uninst-guard: cancelled by user" SetErrorLevel 1 Abort ; Only reached through Yes: Abort does not return. un_guard_close: + IntOp $GuardCloseTried $GuardCloseTried + 1 !insertmacro CockLogUn "uninst-guard: closing cockatrice on request, then checking again" Call un.CloseHolders Goto un_guard_check @@ -1601,7 +1710,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} @@ -1618,10 +1730,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" + ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -1635,7 +1761,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} @@ -1644,10 +1771,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" + ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a3446e42b..a44176f38 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,7 +980,7 @@ 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(); diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index bcf709cb1..98ef6a9c1 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; diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 508d9b3c1..d5bd5251f 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -44,39 +44,123 @@ std::wstring processLabel(const Holder &holder) 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; - HWND topmost = nullptr; ///< first visible window in Z-order: a dialog when one is up - HWND main = nullptr; ///< first visible window without an owner: the one to close - HWND any = nullptr; ///< first window at all, used when nothing is visible + std::vector windows; ///< every top-level window of the process, in Z-order }; -BOOL CALLBACK findProcessWindow(HWND window, LPARAM parameter) +BOOL CALLBACK collectProcessWindow(HWND window, LPARAM parameter) { auto &search = *reinterpret_cast(parameter); DWORD processId = 0; GetWindowThreadProcessId(window, &processId); - if (processId != search.processId) { - return TRUE; - } - if (search.any == nullptr) { - search.any = window; - } - // A tray icon leaves the main window hidden, so a window that is not visible is still - // kept as a fallback. EnumWindows walks the Z-order top down, so the first visible - // window is the one the user would see in front. - if (IsWindowVisible(window) != FALSE) { - if (search.topmost == nullptr) { - search.topmost = window; - } - if (search.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { - search.main = window; - } + 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 visible window without an owner: the application's own + HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up + bool modalOpen = false; ///< the window a close would reach is disabled, so a dialog of its own is up +}; + +/// Only the windows the user could answer are candidates - see isAnswerableWindow. An +/// instance left with nothing but Qt's own bookkeeping windows visible has no window to ask: +/// the caller reports that instead of posting a close the application would answer with a +/// terminate that skips its own confirmation. +CloseTarget chooseCloseTarget(const std::vector &windows) +{ + CloseTarget target; + HWND topmost = nullptr; + for (const HWND window : windows) { + if (IsWindowVisible(window) == FALSE || !isAnswerableWindow(window)) { + continue; + } + if (topmost == nullptr) { + topmost = window; + } + if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + target.main = window; + } + } + target.toRaise = topmost != nullptr ? topmost : target.main; + // A modal dialog disables the windows behind it, so a disabled main window is the + // instance asking a question of its own. + target.modalOpen = + target.toRaise != nullptr && IsWindowEnabled(target.main != nullptr ? target.main : target.toRaise) == FALSE; + 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 that needs the next close to bring it back into view. +std::wstring describeWindowState(HWND window) +{ + 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 = IsWindowVisible(window) != FALSE ? L", window still open" : 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. /// @@ -133,6 +217,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un std::vector lines; std::vector asked; std::vector askedLines; + std::vector askedWindows; for (const Holder &holder : holders) { if (holder.isService || !isCockatriceImage(holder.imageName)) { @@ -144,23 +229,41 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un WindowSearch search; search.processId = holder.processId; - EnumWindows(findProcessWindow, reinterpret_cast(&search)); - if (search.any == nullptr) { + EnumWindows(collectProcessWindow, reinterpret_cast(&search)); + if (search.windows.empty()) { lines.push_back(processLabel(holder) + L" has no window to close, left to you"); continue; } - const HWND target = - search.main != nullptr ? search.main : (search.topmost != nullptr ? search.topmost : search.any); - const HWND toRaise = search.topmost != nullptr ? search.topmost : target; + const CloseTarget target = chooseCloseTarget(search.windows); + if (target.main == nullptr) { + // Nothing visible that a user could answer: the instance is hidden - in the + // notification area, say - or has nothing but Qt's own bookkeeping windows on + // screen. Neither is a window a close could reach, and showing one from here does + // not help: Windows would display it while the application still believes it is + // hidden, so it stays blank and answers nothing. The instance is named and left to + // the user, who can open it from the notification area. + lines.push_back(processLabel(holder) + L" has no window to ask, left to you"); + continue; + } std::wstring line = processLabel(holder); - line += bringForward(toRaise) ? L" brought forward" : L" could not be brought forward"; + line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; + + // Posting the close to an instance that is asking a question of its own is what made + // an earlier attempt useless: the client accepts it, hides this window and the dialog + // with it, and stays alive inside the nested loop the dialog opened, where nothing can + // reach it any more. The question is the request now, so it is brought forward and + // left alone. + if (target.modalOpen) { + lines.push_back(line + L", and is waiting for an answer to a question of its own"); + continue; + } // 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; - if (PostMessageW(target, WM_CLOSE, 0, 0) == FALSE) { + if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) { // Read the error before CloseHandle, which is free to reset it. const DWORD postError = GetLastError(); if (process != nullptr) { @@ -177,6 +280,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un } else { askedLines.push_back(lines.size()); asked.push_back(process); + askedWindows.push_back(target.main); } lines.push_back(std::move(line)); } @@ -186,8 +290,12 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un 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; - lines[askedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" - : L", still running after " + std::to_wstring(elapsed) + L" ms"; + if (exited) { + lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms"; + } else { + lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" + + describeWindowState(askedWindows[index]); + } } } for (const HANDLE process : asked) { @@ -198,15 +306,61 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un result.report = L"nothing is holding the files"; return result; } - for (const std::wstring &line : lines) { - if (!result.report.empty()) { - result.report += L"; "; - } - result.report += line; - } - if (result.report.size() > REPORT_CHAR_MAX) { - result.report.erase(REPORT_CHAR_MAX); - result.report += L"..."; - } + result.report = joinReport(lines); + return result; +} + +EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +{ + EndHoldersResult result; + std::vector lines; + std::vector ended; + std::vector endedLines; + + for (const Holder &holder : holders) { + if (holder.isService || !isCockatriceImage(holder.imageName)) { + // The same split as the close: Oracle writes the card database in place and a + // service has nothing to ask, so neither is ever ended here. + lines.push_back(processLabel(holder) + L" left to you"); + continue; + } + + 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 index 427da5ea6..4f9f5f0af 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -28,19 +28,55 @@ struct CloseHoldersResult * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card * database in place - and so is every service. * - * Two things happen per instance. Its top-level window is restored and brought to the - * foreground, because Cockatrice may answer the close with a confirmation of its own that - * would otherwise open behind the installer's prompt, and then WM_CLOSE is posted to the - * main window, the same request as clicking its close button. Stopping the process is - * never attempted. + * One thing happens per instance: its main window is brought to the foreground, because + * Cockatrice may answer the close with a confirmation of its own that would otherwise open + * behind the installer's prompt. Then WM_CLOSE is posted to the main window, the same + * request as clicking its close button - unless the instance is asking a question of its + * own, which disables that window: posting the close then only hides the question, so it is + * brought forward and left to the user. An instance with no window a user could answer - + * one hidden in the notification area, say - is named and left to the user as well; showing + * its windows from here does not help, because the application would still believe they are + * hidden and never paint them. Stopping the process is never attempted. * * 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. + * 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, 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 */ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); +/** + * @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 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, so the caller only gets here after the user picked it from a prompt that says so. + * Only cockatrice.exe is ended - Oracle writes the card database in place and Servatrice is a + * service, so both are named and left to the user, exactly as they are for the close. + * + * 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 endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); + #endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index db46035d3..3f1556b75 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -20,6 +20,11 @@ constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; /// 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 @@ -34,22 +39,40 @@ enum ExitCode EXIT_PROBE_FAILED = 3, }; -/// The probe has two modes, selected by the first argument: +/// The probe has three 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 and wait for it to be gone, for the last +/// resort the guard prompt offers after a close was asked for twice /// -/// Both answer from two lookups that cover each other's blind spot: the Restart Manager +/// All three answer from two lookups that cover each other's blind spot: the Restart Manager /// names whoever has a payload file open, and every process running from the directory 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. Neither lookup alone is the guard's authority: an instance the file /// handles do not name is exactly the case that left a half-replaced install behind. -bool closeModeRequested(int argumentCount, char **arguments) +enum class Mode { - return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; + Name, ///< name the holders + Close, ///< ask the holders to close + End, ///< end the holders +}; + +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; + } + } + return Mode::Name; } /// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes @@ -96,10 +119,17 @@ int main(int argumentCount, char **arguments) // 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; }); @@ -116,20 +146,25 @@ int main(int argumentCount, char **arguments) return EXIT_PROBE_FAILED; } - const bool closeMode = closeModeRequested(argumentCount, arguments); + const Mode mode = requestedMode(argumentCount, arguments); if (holders.empty()) { // No output in the naming mode: the installer words the "nothing is holding them" - // case, and it owns the message either way. The close mode says it itself, because - // its line is logged verbatim next to what it was asked to do. - if (closeMode) { + // case, and it owns the message either way. The acting modes say it themselves, + // because their line is logged verbatim next to what they were asked to do. + if (mode != Mode::Name) { writeLine(L"nothing is holding the files"); } return EXIT_NO_HOLDERS; } - if (!closeMode) { + if (mode == Mode::Name) { writeLine(formatHolders(holders)); return EXIT_HOLDERS_FOUND; } + if (mode == Mode::End) { + const EndHoldersResult result = endCockatriceHolders(holders, END_WAIT_MILLISECONDS); + writeLine(result.report); + return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; + } const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS); writeLine(result.report); diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index c00e5da8c..1abfcb352 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -22,9 +22,28 @@ 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; + /// The payload files the installer replaces by name; the DLLs are matched by mask below. constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; +/// The payload programs, by image name. A process running one of these is what the guard +/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. +constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; + +bool isPayloadProgram(const std::wstring &imageName) +{ + for (const wchar_t *program : PAYLOAD_PROGRAMS) { + if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { + return true; + } + } + return false; +} + void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -214,6 +233,45 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st 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; @@ -222,10 +280,10 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) } const std::wstring prefix = directoryPrefixOf(installDir); - // Never the probe itself: it runs from the directory being replaced when the - // uninstaller asks in place or the installer was copied next to the payload, and - // naming it would ask the user to close the process that is doing the asking. - const DWORD self = GetCurrentProcessId(); + // 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) { @@ -235,7 +293,7 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) entry.dwSize = sizeof(entry); if (Process32FirstW(snapshot, &entry) != FALSE) { do { - if (entry.th32ProcessID == self) { + 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 diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index 6dcd91108..044cbe449 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -52,9 +52,30 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st * database server next to a Cockatrice install) is never named. * * @param installDir the directory being replaced, as a full path. - * @return one entry per process running from it, in snapshot order, without the probe - * itself; empty when the directory is empty or no process runs from it. + * @return one entry per process running from it, in snapshot order, without the processes + * named by askingProcessIds(); empty when the directory is empty or no process + * 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 From bf3562a85af384f1eda194db83fadbdf24846841 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:09:21 +0100 Subject: [PATCH 131/247] [Installer] Offer to end running Cockatrice immediately when auto-close fails (#7675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Offer to end running Cockatrice immediately when auto-close fails * [Installer] Keep the way out that costs nothing in the first prompt The prompt now offers to end Cockatrice, so what the deleted close-again text said has to survive in it: an instance the probe reports as waiting for an answer to a question of its own has been brought forward, and answering that window is the way out that ends nothing. Say so, and say where to go when Yes leaves it listed - Task Manager - so an end that cannot take effect does not loop on the same dialog. * [Installer] Bring the comments back in line with the code GuardCloseTried no longer counts closes, and the file header, CloseHolders and GuardAppsNotRunning still described an escalation after a second close, as did the probe's --end-holders note. * [Installer] End Oracle and Servatrice too when the guard's end answer is confirmed The guard's end answer only ever stopped cockatrice.exe, so an Oracle holding the card database or a Servatrice service kept the payload locked and the prompt came back with nothing left that could free it. The probe now ends every program the payload installs, services included: isPayloadProgram() - the list restart_manager_holders already used for its parent walk - decides the set, so a scanner holding a DLL or a program the user keeps in the install directory is still named and left to the user. A service is terminated rather than sent a stop through the SCM, which notices the process exit and marks it stopped afterwards. The guard prompt now says what ending costs: an unsaved deck, an unwritten setting, players on a running Servatrice, and an Oracle mid-way through the card database. It also keeps the notification-area hint, which the probe's "has no window to ask" answer depends on, and drops the claim that Oracle and Servatrice had been brought forward. * [Installer] Give every running-program prompt the same end-or-retry answers Four prompts meet a running program and each offered a different set: the guard ended it, the launcher wait only kept waiting, and the recovery prompt after a failed purge could only ask Cockatrice to close. With Oracle or Servatrice holding the files, that last one had no answer that could free anything - Yes and No both retried the purge and Cancel aborted an install whose old version was already gone. All four now follow the guard's shape: one automatic attempt, then a prompt whose Yes ends the programs it names and whose No retries, with Cancel to give up. - RecoverFromHolders closes once and purges before it asks anything, then offers EndHolders - the same end answer as the guard - instead of a close the user had to trigger himself. - WaitForLauncherToExit can end the launcher by pid, the one process it holds a pid for, rather than looping 60 s waits with Cancel as the only way out. - END_COST and AFTER_CLOSING_GUIDANCE are !defines, so the cost of ending and the guidance for the answers that end nothing read the same in every prompt. * [Installer] End the launcher through the handle it was waited on The "end it" answer used to look the launcher's pid up again after a dialog that can stay open as long as the user takes, so a pid that had been reused in the meantime - or a /PID= that was never the launcher - would be terminated instead. The handle the wait already holds names that process and cannot be handed to another, so the terminate answer now acts on it. PROCESS_TERMINATE is requested along with PROCESS_SYNCHRONIZE; when only the synchronize right is granted the launcher is still waited on and the answer falls back to asking it to close, instead of reporting a live launcher as gone and writing the payload over it. * [Installer] Say what the end answer ends, and report a failed end The prompt offered "end every program listed above", but EndHolders only ends the programs the payload installs: a scanner holding a payload DLL, or a service the Restart Manager names by its long name, was listed and then silently left alone, so Yes looked like it did nothing and the same dialog came back unchanged. The wording now says which programs Yes ends and that anything else has to be closed by hand, and the round after an end that did not free the files says so, so the same dialog no longer reappears word for word. The three prompts are one macro so they cannot drift apart again. The added sentences eat into the 1024 characters one NSIS string holds, so the holder line's budget drops from 400 to 320 characters to keep the longest message (cancel line included) inside it: 977 characters, measured against the worst case the probe can produce. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 355 +++++++++++------- .../installer-holder-probe/close_holders.cpp | 13 +- tools/installer-holder-probe/close_holders.h | 17 +- tools/installer-holder-probe/holder_line.h | 13 +- tools/installer-holder-probe/main.cpp | 7 +- .../restart_manager_holders.cpp | 20 +- .../restart_manager_holders.h | 12 + 7 files changed, 269 insertions(+), 168 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 72d5287fc..6b861a370 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -42,16 +42,27 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; How many closes the guard has asked for: 0 before the automatic one, 1 after it, and one -; more for every "Yes" at the prompt. From the second one on, the prompt offers to end the -; instance instead (see GuardAppsNotRunning). +; Whether the guard has already asked Cockatrice to close by itself: 0 before the automatic +; close, 1 after it. The prompt that follows offers to end the instance rather than asking +; a second time (see GuardAppsNotRunning). Var GuardCloseTried +; Set when the user answered Yes to ending the programs and the files were still in use +; afterwards, so the prompt that follows says the end did not help instead of reappearing +; word for word (see CockEndPrompt). +Var GuardEndFailed +; The question CockEndPrompt assembles: one sentence in it is conditional, 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 Var InstallLogPath Var LogHandle Var LogMessage @@ -304,10 +315,10 @@ ${EndIf} ; ; Then make sure no *other* application instance is still running (and holding ; file locks) before the previous version is uninstalled. The probe names them -; and the user is asked to close them; nothing is ever force-stopped, 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. +; 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. @@ -794,13 +805,49 @@ FunctionEnd ; ; 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 killing it mid-write would corrupt it, and Servatrice -; has no window to ask. A close is a request (WM_CLOSE), made automatically once -; because that is what the user started the installer to do, and again whenever -; they ask for it from the prompt. When it is not answered the user is told which -; program is in the way, and once a close has been asked for twice without freeing -; the files they are offered to end Cockatrice themselves - the last resort, with -; what it costs spelled out, and never applied to Oracle or Servatrice. +; the card database in place and can be killed mid-write, and Servatrice may be serving +; a game. A close is a request (WM_CLOSE), made automatically once because that is what +; the user started the installer to do, and only Cockatrice can answer one - the other +; two have no window. When the close is not answered the user is told which program is +; in the way and is offered to end all three themselves - the last resort, with what it +; costs spelled out in the prompt. + +; 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." + +; The other half of that answer - what is left to do when nothing is ended - is shared by +; every prompt that names the holders, for the same reason. +!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." + +; usage: set the variable named by the second argument to the probe's listing, then call +; this. Sets $GuardPrompt to the question the guard, the uninstaller's guard and the +; recovery path all 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. +; +; 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 and the sentence only a failed end adds, 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 Cancel gives up, "installation" or "uninstallation" +!macro CockEndPrompt header listing cancelWord + StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" + ; Said only after the previous answer was Yes: without it the same dialog reappears word + ; for word, which is what "nothing happened" looked like - the end can fail on a process + ; the installer may not open, and it never touches a program that is not one of ours. + ${If} $GuardEndFailed = 1 + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nEnding them did not free the files." + ${EndIf} + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to try again after closing anything else listed above by hand. ${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nChoose Cancel to cancel 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 @@ -840,7 +887,7 @@ FunctionEnd ; replaced, plus the processes running from the directory. tools\installer-holder-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, so both stay the user's job, as the prompt says. +; window to ask, so both are left alone here and only ended by the explicit answer. ; ; Each instance is brought to the foreground before WM_CLOSE is posted to its main window. ; WM_CLOSE is answered, not obeyed - Cockatrice asks before it quits while a card database @@ -852,9 +899,9 @@ FunctionEnd ; 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 it is a separate -; answer the prompt offers once a close has been asked for twice and ignored, spelled out -; with what it costs - see EndHolders - and it is only ever applied to cockatrice.exe. +; 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")' @@ -871,13 +918,14 @@ Function ${prefix}CloseHolders FunctionEnd ; usage: set $GuardDir to the directory being replaced, then call this. Ends the running -; Cockatrice instances the probe names. +; 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. The probe -; ends cockatrice.exe and nothing else - Oracle writes the card database in place and -; Servatrice is a service, so both stay the user's job, exactly as they do for the close. +; 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")' @@ -888,7 +936,7 @@ Function ${prefix}EndHolders Pop $R4 Push "end-holders: probe exit=$R3 [$R4]" Call ${prefix}CockLogWrite - DetailPrint "Ending Cockatrice: $R4" + DetailPrint "Ending Cockatrice, Oracle and Servatrice: $R4" FunctionEnd !macroend @@ -902,9 +950,9 @@ FunctionEnd ; 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 Cockatrice may still be waiting for an answer in -; its own confirmation. It offers one more close (Yes), a plain re-check for after the -; user closed them by hand (No), and Cancel. Once a close has been asked for twice without -; freeing the files, it offers to end Cockatrice instead - the last resort, see EndHolders. +; its own confirmation. It offers to end the programs the payload installs - and only those, +; which the text says - with what that costs (Yes), a plain re-check for after the user +; closed things by hand (No), and Cancel - 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 @@ -914,6 +962,7 @@ FunctionEnd Function GuardAppsNotRunning !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" StrCpy $GuardCloseTried 0 + StrCpy $GuardEndFailed 0 guard_check: Call RunHolderProbe ${If} $GuardExit == "0" @@ -936,45 +985,31 @@ Function GuardAppsNotRunning Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; A close that has been asked for twice and changed nothing is where asking again - ; stops being a plan: the instance is either waiting on a question the user has not - ; answered, or stuck where a close cannot reach it. Offer to end it - the only thing - ; left that can free the files - with the cost in the text, because a client that is - ; still running may have an unsaved deck, a game or a settings write in flight. - ${If} $GuardCloseTried >= 2 - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ - IDYES guard_end IDNO guard_close - !insertmacro CockLog "guard: cancelled by user" - StrCpy $AbortReason \ - "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." - Call AbortInstall - ; Only reached through Yes: AbortInstall does not return. - guard_end: - !insertmacro CockLog "guard: ending cockatrice on request, then checking again" - Call EndHolders - ; Back to the ordinary prompt afterwards: Oracle and Servatrice are never ended, - ; so what is left may still have to be closed by hand. - StrCpy $GuardCloseTried 1 - Goto guard_check - ${EndIf} - ; Three answers, and the close is one of them: the automatic attempt above ran - ; before this prompt and could not have answered Cockatrice's own confirmation, so a - ; second close is often exactly what is needed once the user has dealt with that - ; window. NSIS does not let a MessageBox relabel its buttons, so the text says what - ; Yes, No and Cancel do. - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ - IDYES guard_close IDNO guard_check + ; The automatic close already ran once. Asking Cockatrice to close again is unlikely + ; to help: an instance that did not answer the first one is usually sitting on a + ; confirmation of its own, and the close probe leaves such an instance alone so that a + ; second WM_CLOSE cannot hide the window the question is in - which is how a pre-3.1.2 + ; update used to leave a headless process behind. An instance with no window has + ; nothing to ask at all. Ending is what is left that can free the files, so the prompt + ; offers it here, with what it costs in the text: an unsaved deck, a game, a settings + ; write, and an Oracle that may be mid-way through the card database. The answers that + ; end nothing stay reachable from the same dialog: the instance's own confirmation, + ; and closing anything by hand. + !insertmacro CockEndPrompt "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_check !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." Call AbortInstall ; Only reached through Yes: AbortInstall does not return. - guard_close: - IntOp $GuardCloseTried $GuardCloseTried + 1 - !insertmacro CockLog "guard: closing cockatrice on request, then checking again" - Call CloseHolders + guard_end: + !insertmacro CockLog "guard: ending the payload programs on request, then checking again" + Call EndHolders + ; Everything the payload installs is ended, but a program that could not be opened + ; (another account, a protected process) survives, and a program that is not one of + ; ours is never touched at all, so what is left may still have to be closed by hand. + ; Go back to the same prompt, which says the end did not free the files. + StrCpy $GuardEndFailed 1 Goto guard_check ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager @@ -1081,17 +1116,24 @@ Function WaitForLauncherToExit !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 Retry below, so a launcher that exited in the meantime answers + ; 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 @@ -1101,7 +1143,8 @@ Function WaitForLauncherToExit 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 @@ -1110,9 +1153,18 @@ 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 @@ -1121,26 +1173,42 @@ Function WaitForLauncherToExit DetailPrint "Waiting for the application that started this installer to exit ..." ; WAIT_OBJECT_0 is 0; WAIT_TIMEOUT is 258 and a failed wait reports 0xFFFFFFFF. System::Call 'kernel32::WaitForSingleObject(p R0, i $WaitSlice) i.R1' - System::Call 'kernel32::CloseHandle(p R0)' ${If} $R1 != 0 - !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1)" + ; $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." - ; Two answers, and "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 - and the guard that runs next names the - ; same process by its image path and stops there anyway, so an "ignore" would - ; only put the same question a moment later, after a close attempt nobody asked - ; for. Waiting again is what helps: the client is most likely showing its own - ; "quit anyway?" confirmation, and that window is the thing to answer. - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "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. Answer that window - choose Retry to bring it to the front, ask it to close again and keep waiting. If it does not answer at all, close it from Task Manager first.$\r$\n$\r$\nChoose Retry to wait again, or Cancel to cancel the installation." \ - IDRETRY wait_launcher_retry + ; Same three answers as every other prompt that waits on a running program, and + ; "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, No asks it to close again and keeps + ; waiting, Cancel cancels - the launcher is the client the user started this update + ; from, and ending it is the same offer the guard makes a moment later. + 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 bring it to the front, ask it to close again and 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 Retry: AbortInstall does not return. + ; 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. @@ -1148,11 +1216,13 @@ Function WaitForLauncherToExit ; The same request the window's close button makes, and the same close the guard ; would send a moment later. It brings the client forward as well, which is the ; part that helps when the confirmation is behind something else. Nothing is - ; force-stopped: a client that is waiting for an answer keeps the last word. + ; force-stopped here: the answer that does that is Yes. StrCpy $GuardDir $INSTDIR Call CloseHolders 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 @@ -1513,19 +1583,25 @@ FunctionEnd ; 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 - ask, close what this installer owns, purge -; again, and retry while the user keeps asking. Sets $StaleRuntimeAfterPurge to the last -; purge's verdict, so the caller still aborts when the files survive. +; 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. ; -; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the window's close -; button) and 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. Oracle and Servatrice have no -; window to send a close to at all, and nothing here is force-stopped: Oracle writes the -; card database in place and killing it mid-write would corrupt it. Everything the probe -; named that this installer cannot close - those two included - is left to the user, whom -; the prompt names it to. +; 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, @@ -1534,33 +1610,37 @@ FunctionEnd ; 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 -; close, and the abort's per-file diagnostics already record what Windows said. +; end, and the abort's per-file diagnostics already record what Windows said. ; -; Every round offers all three answers: Yes closes Cockatrice and purges again, No purges -; again without asking it to close (for when the user closed things by hand), and Cancel -; gives up. The close stays on offer after one that did nothing because the usual reason is -; Cockatrice's own confirmation still waiting for an answer - the probe waits for that, the -; user can answer it in the application's window, and the next round can then collect the -; process the earlier attempt could not. Each round re-reads the holders, so the prompt -; names what survived the last attempt rather than repeating the first listing. +; Every round offers all three answers: Yes ends the programs the payload installs - and +; says so, because anything else the probe named is left to the user - and purges again, No +; purges again without ending anything (for when the user closed things by hand), and +; Cancel gives up. Each round re-reads the holders, so the prompt names what survived the +; last attempt rather than repeating the first listing. Function RecoverFromHolders ${If} $HolderFound != 1 !insertmacro CockLog "recover-holders: no holder was named, nothing to offer" Return ${EndIf} - rfh_prompt: - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and try the removal again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose No to try again after closing the programs yourself, or Cancel to cancel the installation." \ - IDYES rfh_close IDNO rfh_retry - !insertmacro CockLog "recover-holders: declined by user" - Return - rfh_close: - !insertmacro CockLog "recover-holders: closing cockatrice on request, holders were [$HolderOutput]" - ; The close is a request, not a fact: CloseHolders waits for the instances it asked to - ; exit, so a confirmation of Cockatrice's that is still up is reported as still running - ; instead of being measured as a successful close. + StrCpy $GuardEndFailed 0 + ; 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 + Goto rfh_retry + rfh_prompt: + ; The same question the guard asks, so the two cannot drift apart in what they promise. + !insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end IDNO rfh_retry + !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 + ; The next prompt says the end did not free the files, instead of naming the same + ; survivors word for word. + StrCpy $GuardEndFailed 1 rfh_retry: Call RetryDllPurge StrCpy $StaleRuntimeAfterPurge $R0 @@ -1572,7 +1652,7 @@ Function RecoverFromHolders Return ${EndIf} ; Still stuck: refresh the names, so the next prompt and the log describe what - ; survived the close and not what was there before it. + ; survived the last attempt and not what was there before it. Call LogStaleRuntimeDiagnostics ${If} $HolderFound != 1 Return @@ -1591,6 +1671,7 @@ FunctionEnd Function un.GuardAppsNotRunning StrCpy $GuardCloseTried 0 + StrCpy $GuardEndFailed 0 un_guard_check: !insertmacro CockLogUn "uninst-guard: checking $GuardDir" Call un.RunHolderProbe @@ -1609,35 +1690,27 @@ Function un.GuardAppsNotRunning Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; Same escalation as in the installer's guard: after a second close that changed - ; nothing, offer to end the instance, with what it costs in the text. - ${If} $GuardCloseTried >= 2 - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ - IDYES un_guard_end IDNO un_guard_close - !insertmacro CockLogUn "uninst-guard: cancelled by user" - SetErrorLevel 1 - Abort - ; Only reached through Yes: Abort does not return. - un_guard_end: - !insertmacro CockLogUn "uninst-guard: ending cockatrice on request, then checking again" - Call un.EndHolders - StrCpy $GuardCloseTried 1 - Goto un_guard_check - ${EndIf} - ; Same three answers as the installer's guard; the automatic close above already - ; ran once before this prompt. - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ - IDYES un_guard_close IDNO un_guard_check + ; Same approach as in the installer's guard: the automatic close already ran once, and + ; asking again is unlikely to help an instance that is sitting on its own confirmation + ; or has no window a close could reach. Ending is what is left that can free the files, + ; so the prompt offers it here, with what it costs in the text, while keeping the + ; answers that end nothing - the instance's own question, and closing anything by hand + ; - reachable from the same dialog. CockEndPrompt words it, so this copy and the + ; installer's promise the same thing. + !insertmacro CockEndPrompt "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_check !insertmacro CockLogUn "uninst-guard: cancelled by user" SetErrorLevel 1 Abort ; Only reached through Yes: Abort does not return. - un_guard_close: - IntOp $GuardCloseTried $GuardCloseTried + 1 - !insertmacro CockLogUn "uninst-guard: closing cockatrice on request, then checking again" - Call un.CloseHolders + un_guard_end: + !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again" + Call un.EndHolders + ; Everything the payload installs is ended, but a program that could not be opened + ; (another account, a protected process) survives, and a program that is not one of + ; ours is never touched at all, so what is left may still have to be closed by hand. + ; Go back to the same prompt, which says the end did not free the files. + StrCpy $GuardEndFailed 1 Goto un_guard_check ${EndIf} ; Same reasoning as in the installer: without an answer from the probe we cannot tell @@ -1950,9 +2023,9 @@ ${If} $StaleRuntimeAfterPurge = 1 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. Offer to close and purge again, - ; repeating while the user keeps saying Yes; this updates - ; $StaleRuntimeAfterPurge with the last retry's verdict. + ; leaves the user with no working Cockatrice. It closes once, then offers to end + ; what is left or to try the removal again; this updates $StaleRuntimeAfterPurge + ; with the last retry's verdict. Call RecoverFromHolders ${Else} ; Nothing is holding them. The three attempts in RetryDllPurge were two seconds diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index d5bd5251f..791347408 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -1,5 +1,7 @@ #include "close_holders.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. @@ -310,7 +312,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un return result; } -EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +EndHoldersResult endHolders(const std::vector &holders, unsigned long waitMilliseconds) { EndHoldersResult result; std::vector lines; @@ -318,13 +320,16 @@ EndHoldersResult endCockatriceHolders(const std::vector &holders, unsign std::vector endedLines; for (const Holder &holder : holders) { - if (holder.isService || !isCockatriceImage(holder.imageName)) { - // The same split as the close: Oracle writes the card database in place and a - // service has nothing to ask, so neither is ever ended here. + 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()) + diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 4f9f5f0af..0e2b93b26 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -63,13 +63,20 @@ struct EndHoldersResult }; /** - * @brief Ends every running Cockatrice the probe named. + * @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, so the caller only gets here after the user picked it from a prompt that says so. - * Only cockatrice.exe is ended - Oracle writes the card database in place and Servatrice is a - * service, so both are named and left to the user, exactly as they are for the close. + * 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. @@ -77,6 +84,6 @@ struct EndHoldersResult * @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 endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); +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..163112ff6 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 that also says the last end + * freed nothing, plus the "; and N more" the summary appends after the budget - complete, + * including the Cancel line 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/main.cpp b/tools/installer-holder-probe/main.cpp index 3f1556b75..0cbecb9f2 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -45,8 +45,9 @@ enum ExitCode /// 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 and wait for it to be gone, for the last -/// resort the guard prompt offers after a close was asked for twice +/// --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 /// /// All three answer from two lookups that cover each other's blind spot: the Restart Manager /// names whoever has a payload file open, and every process running from the directory is @@ -161,7 +162,7 @@ int main(int argumentCount, char **arguments) return EXIT_HOLDERS_FOUND; } if (mode == Mode::End) { - const EndHoldersResult result = endCockatriceHolders(holders, END_WAIT_MILLISECONDS); + const EndHoldersResult result = endHolders(holders, END_WAIT_MILLISECONDS); writeLine(result.report); return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; } diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index 1abfcb352..e8c982f26 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -34,16 +34,6 @@ constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L" /// exists to name, so askingProcessIds() stops its walk up the parent chain at one. constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; -bool isPayloadProgram(const std::wstring &imageName) -{ - for (const wchar_t *program : PAYLOAD_PROGRAMS) { - if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { - return true; - } - } - return false; -} - void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -144,6 +134,16 @@ Holder holderOf(const RM_PROCESS_INFO &info) } } // namespace +bool isPayloadProgram(const std::wstring &imageName) +{ + for (const wchar_t *program : PAYLOAD_PROGRAMS) { + if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { + return true; + } + } + return false; +} + std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index 044cbe449..c14499941 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -21,6 +21,18 @@ */ std::vector collectPayloadFiles(const std::wstring &installDir); +/** + * @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 ends the + * same set it names. Matched case-insensitively on the image name alone: only holders are + * ever tested against it, and a holder is a process that has a payload file open or runs + * from the install directory. + * + * @param imageName the file name of a process image, empty when it could not be read. + */ +bool isPayloadProgram(const std::wstring &imageName); + /** * @brief Names the applications and services that have one of @p files open or mapped. * From a83d82b5e82c5801fc5b9b5e7bd33d191409a836 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:08:54 +0100 Subject: [PATCH 132/247] [Installer] Recover leftover payload files when previous uninstaller leaves them behind (#7676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Recover leftover payload files when previous uninstaller leaves them behind * [Installer] Fix leftover recovery on the 64-bit auto-uninstall path - Record the 64-bit uninstaller exit code after ExecWait returns. It was captured before the call (always empty), so WaitForOldPayloadToDisappear always aborted before RemoveOldPayloadLeftovers could run. - Restore the pre-uninstall user-data backup before aborting out of the wait, including the guard's cancel and probe-failure prompts, so the only copy cannot be stranded in %TEMP% for the next run to wipe. - Report the leftover removal as done only when every payload executable is gone and RetryDllPurge reports no stale DLLs, instead of checking cockatrice.exe alone. - Return directly after a successful recovery instead of reusing the poll counter, so the log stops claiming the directory "was already clear" and the normal settle delay is kept. * [Installer] Delete the old uninstaller only after recovery succeeds RemoveOldPayloadLeftovers cleared the attribute and deleted $INSTDIR\uninstall.exe on every attempt, before the attempt was known to work. On failure the installer aborts telling the user to run it again, but UninstallString then points at a missing uninstaller: ExecWait leaves the exit code empty, WaitForOldPayloadToDisappear takes the "cancelled or failed" branch, and RemoveOldPayloadLeftovers is gated on exit code 0, so no later run can ever get past it - a half-removed old install the previous revision never produced. Delete it in rop_done instead, after $OldPayloadRemoved is set. WriteUninstaller recreates it on success anyway, and nothing in the success verdict depends on it being gone. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 158 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 4 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 6b861a370..cda370670 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,6 +15,17 @@ Var ReinstallMode Var GuardDir Var OldUninstallString Var OldInstallDir +; What ExecWait reported for the previous version's uninstaller (empty when it could not be +; started at all). 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 +; 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 @@ -198,6 +209,7 @@ 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 @@ -404,7 +416,9 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall +StrCpy $R5 "" ExecWait '$R0 _?=$OldInstallDir' $R5 +StrCpy $OldUninstallExit $R5 !insertmacro CockLog "uninstall (32-bit): exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -419,9 +433,11 @@ ${If} ${RunningX64} StrCpy $OldInstallDir $INSTDIR !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done64 + StrCpy $R5 "" DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall ExecWait '$R0 _?=$OldInstallDir' $R5 + StrCpy $OldUninstallExit $R5 !insertmacro CockLog "uninstall (64-bit): exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -439,6 +455,12 @@ FunctionEnd ; 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 == "" @@ -449,10 +471,37 @@ 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" - StrCpy $AbortReason \ - "The previous version in $OldInstallDir is still there after its uninstaller ran.$\r$\n$\r$\nIf that uninstaller was cancelled or reported an error, close anything still using those files and run this installer again." - Call AbortInstall + !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, failed, or could not be started at all (empty code): 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 @@ -467,6 +516,97 @@ ${If} $R2 > 0 ${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. +; 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 @@ -1000,6 +1140,9 @@ Function GuardAppsNotRunning !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\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: @@ -1031,6 +1174,9 @@ Function GuardAppsNotRunning guard_unknown_abort: StrCpy $AbortReason \ "The installation was cancelled because it could not check 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 @@ -1818,7 +1964,9 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall + StrCpy $R5 "" ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + StrCpy $OldUninstallExit $R5 !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -1854,7 +2002,9 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall + StrCpy $R5 "" ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + StrCpy $OldUninstallExit $R5 !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall From d89b5023422a4888d463c5c9a94ed2e62fbd4220 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:19:42 +0100 Subject: [PATCH 133/247] [Client] Report password-reset rate limits instead of a generic failure (#7677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Report password-reset rate limits instead of a generic failure sigForgotPasswordError carried no response code, so rate limiting, a disabled reset feature, an unknown user and a bad token all produced "contact the server operator". Users who were only rate limited retried, which kept them rate limited. * [Client] Move the reset-password warning out of the response switch --------- Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 38 +++++++++++++++---- .../remote_connection_controller.h | 2 +- .../network/client/remote/remote_client.cpp | 6 +-- .../network/client/remote/remote_client.h | 2 +- 4 files changed, 36 insertions(+), 12 deletions(-) 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..4ac29b49b 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -517,14 +517,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/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index e02f5d3d8..51cb73750 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -837,7 +837,7 @@ void RemoteClient::requestForgotPasswordResponse(const Response &response) emit sigPromptForForgotPasswordReset(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -873,7 +873,7 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response) derivedVerifier.clear(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -908,7 +908,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, From 5cf3358c997254eb8cf97fde3f906e5ef9cf24a4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:48:37 +0100 Subject: [PATCH 134/247] [Client] Stop reporting local login aborts as missing client features (#7678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Stop reporting local login aborts as missing client features Local aborts (unusable saved verifier, refused authentication downgrade, failed scrypt derivation) reuse RespClientUpdateRequired with an empty feature list, and the handler discarded the reason and advised a client update. Report the abort with the cause instead, and report a strict server's challenge_response_auth answer to a credential-less login as the missing password it is. * [Client] Keep the login abort messages in one named set The local login aborts each carried their wording inline at the emit site, so changing the tone or the remedy meant editing several functions. Collect them as named constants next to RemoteClient instead, translated at the use sites with the RemoteClient context so lupdate still picks them up. * [Client] Reword the login abort explanations to match what happens The first wording sent users to a server operator to 'migrate' an account, which operators cannot do, and told them to 'save' a login the client saves by itself. It also blamed the device for a derivation failure the server's parameters can cause, and the dialog led with a line that said nothing while the reason sat in the informative slot. Say what the client actually does in each case, give the step that works, and show the reason as the dialog's main text. The unreachable fallback sentence about the login not reaching the server is gone. --------- Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 26 +++++-- .../network/client/remote/remote_client.cpp | 73 +++++++++++++++---- 2 files changed, 78 insertions(+), 21 deletions(-) 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 4ac29b49b..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; } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 51cb73750..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); } } From 06da7f815a4f24abef0b2f173971e904f2bef402 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:00:42 +0100 Subject: [PATCH 135/247] [Installer] End a launcher that has no window to ask instead of prompting (#7681) * [Installer] End a launcher that has no window to ask instead of prompting The in-app updater starts this installer detached and then closes itself. A client that accepts that close hides its window before it exits, and if it then hangs in shutdown there is nothing left to ask: the holder probe deliberately never posts WM_CLOSE to an instance it cannot see a window for, so the "ask it to close again" answer only repeated itself until the user picked Cancel and the installation aborted (Sentry COCKATRICE-DESKTOP-J). The launcher wait now asks the launcher itself before asking the user. A new probe mode, --close-launcher, answers whether that one process has a window a close could reach at all; it is limited to a pid that still runs from the directory being replaced, so a reused process id cannot be hit. When there is no window, the installer gives the shutdown it already started one full grace and then ends it through the handle it waited on, which is what the dialog's Yes answer did. The prompt remains for the case it exists for: a window is up, the instance may be waiting on its own question, and No now means only "keep waiting". The client is left alone: an already-installed version runs the installer during its own update, so a change there cannot reach the run that aborts. * [libcockatrice_rng] Include immintrin.h for AVX2 builds in SFMT --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- cmake/NSIS.template.in | 110 +++++++++++++++--- .../libcockatrice/rng/sfmt/SFMT.h | 8 ++ .../installer-holder-probe/close_holders.cpp | 30 ++--- tools/installer-holder-probe/close_holders.h | 18 ++- tools/installer-holder-probe/main.cpp | 108 +++++++++++++++-- 5 files changed, 233 insertions(+), 41 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index cda370670..bc121ff25 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -74,6 +74,11 @@ Var WaitSlice ; 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 @@ -1241,6 +1246,33 @@ Function AbortInstall 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. ; @@ -1255,8 +1287,16 @@ FunctionEnd ; 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 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" @@ -1324,14 +1364,58 @@ Function WaitForLauncherToExit ; 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." - ; Same three answers as every other prompt that waits on a running program, and - ; "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, No asks it to close again and keeps - ; waiting, Cancel cancels - the launcher is the client the user started this update - ; from, and ending it is the same offer the guard makes a moment later. + ; 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 bring it to the front, ask it to close again and keep waiting, or Cancel to cancel the installation." \ + "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 \ @@ -1357,14 +1441,12 @@ Function WaitForLauncherToExit 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. + ; 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 - ; The same request the window's close button makes, and the same close the guard - ; would send a moment later. It brings the client forward as well, which is the - ; part that helps when the confirmation is behind something else. Nothing is - ; force-stopped here: the answer that does that is Yes. - StrCpy $GuardDir $INSTDIR - Call CloseHolders Goto wait_launcher_open ${EndIf} System::Call 'kernel32::CloseHandle(p R0)' 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/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 791347408..d1831e4e9 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -15,8 +15,6 @@ namespace { -constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe"; - /// 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; @@ -26,11 +24,6 @@ constexpr unsigned long WAIT_POLL_MILLISECONDS = 200; /// the line stays far below it. constexpr std::size_t REPORT_CHAR_MAX = 900; -bool isCockatriceImage(const std::wstring &imageName) -{ - return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; -} - /// 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. @@ -112,7 +105,7 @@ struct CloseTarget { HWND main = nullptr; ///< first visible window without an owner: the application's own HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up - bool modalOpen = false; ///< the window a close would reach is disabled, so a dialog of its own is up + bool modalOpen = false; ///< the only window a user could answer is the instance's own question }; /// Only the windows the user could answer are candidates - see isAnswerableWindow. An @@ -136,9 +129,11 @@ CloseTarget chooseCloseTarget(const std::vector &windows) } target.toRaise = topmost != nullptr ? topmost : target.main; // A modal dialog disables the windows behind it, so a disabled main window is the - // instance asking a question of its own. - target.modalOpen = - target.toRaise != nullptr && IsWindowEnabled(target.main != nullptr ? target.main : target.toRaise) == FALSE; + // instance asking a question of its own. A visible window with no unowned main window + // next to it is one too: what is on screen is then the question itself - the window + // behind it is hidden, not gone - and there is no window a close could be posted to + // without hiding the question with it. + target.modalOpen = target.toRaise != nullptr && (target.main == nullptr || IsWindowEnabled(target.main) == FALSE); return target; } @@ -213,6 +208,12 @@ bool waitForExit(const std::vector &processes, unsigned long waitMillise } } // 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) { CloseHoldersResult result; @@ -233,18 +234,20 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un search.processId = holder.processId; EnumWindows(collectProcessWindow, reinterpret_cast(&search)); if (search.windows.empty()) { + ++result.withoutWindow; lines.push_back(processLabel(holder) + L" has no window to close, left to you"); continue; } const CloseTarget target = chooseCloseTarget(search.windows); - if (target.main == nullptr) { + if (target.toRaise == nullptr) { // Nothing visible that a user could answer: the instance is hidden - in the // notification area, say - or has nothing but Qt's own bookkeeping windows on // screen. Neither is a window a close could reach, and showing one from here does // not help: Windows would display it while the application still believes it is // hidden, so it stays blank and answers nothing. The instance is named and left to // the user, who can open it from the notification area. + ++result.withoutWindow; lines.push_back(processLabel(holder) + L" has no window to ask, left to you"); continue; } @@ -255,7 +258,8 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un // an earlier attempt useless: the client accepts it, hides this window and the dialog // with it, and stays alive inside the nested loop the dialog opened, where nothing can // reach it any more. The question is the request now, so it is brought forward and - // left alone. + // left alone - including a question whose owner window is hidden, which is the only + // thing left on screen and has no unowned window a close could be posted to. if (target.modalOpen) { lines.push_back(line + L", and is waiting for an answer to a question of its own"); continue; diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 0e2b93b26..643624779 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -11,13 +11,27 @@ * * @c report is one line naming every holder, including the ones this deliberately * leaves alone, because the installer repeats it in its log and in the second prompt. + * @c withoutWindow counts the holders that had no visible window a user could answer: 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 a WM_CLOSE was posted to - std::wstring report; ///< one line, narrowed to ASCII when written + unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to + unsigned int withoutWindow = 0; ///< holders with no visible window a user could answer + 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. diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 0cbecb9f2..10a6c22e4 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -3,6 +3,7 @@ #include "restart_manager_holders.h" #include +#include #include #include #include @@ -14,6 +15,10 @@ 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 @@ -30,16 +35,19 @@ constexpr unsigned long END_WAIT_MILLISECONDS = 5000; /// 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 only for /// a lookup that failed and named nobody: one that failed but still named a process -/// running from the directory is answered with that process. +/// running from the directory is answered with that process. 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 three modes, selected by the first argument: +/// 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 @@ -48,18 +56,22 @@ enum ExitCode /// --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 /// -/// All three answer from two lookups that cover each other's blind spot: the Restart Manager -/// names whoever has a payload file open, and every process running from the directory 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 +/// The guard's three modes answer from two lookups that cover each other's blind spot: the +/// Restart Manager names whoever has a payload file open, and every process running from the +/// directory 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. Neither lookup alone is the guard's authority: an instance the file /// handles do not name is exactly the case that left a half-replaced install behind. enum class Mode { - Name, ///< name the holders - Close, ///< ask the holders to close - End, ///< end the holders + 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) @@ -72,6 +84,9 @@ Mode requestedMode(int argumentCount, char **arguments) if (mode == "--end-holders") { return Mode::End; } + if (mode == "--close-launcher") { + return Mode::CloseLauncher; + } } return Mode::Name; } @@ -92,11 +107,11 @@ void writeLine(const std::wstring &text) WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); } -std::wstring installDirFromEnvironment() +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 {}; } @@ -106,6 +121,68 @@ 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 +/// window to ask is past its own prompts and stuck in shutdown, so no answer the installer +/// offers would ever reach it. Everything else - a window that was asked, or one that is +/// waiting on a question of its own - 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); + writeLine(result.report); + if (result.askedToClose > 0) { + return EXIT_HOLDERS_FOUND; + } + return result.withoutWindow > 0 ? EXIT_NO_WINDOW : EXIT_HOLDERS_FOUND; + } + // Not an answer: the lookup may have failed or refused the image, and the installer + // holds a live handle on that process, so "not found" must not read as "gone". The + // installer shows its dialog for this code instead of acting on it. + writeLine(L"the launcher is not running from the directory being replaced, left to you"); + return EXIT_PROBE_FAILED; +} } // namespace int main(int argumentCount, char **arguments) @@ -116,6 +193,14 @@ int main(int argumentCount, char **arguments) return EXIT_NOT_CONFIGURED; } + const Mode mode = requestedMode(argumentCount, arguments); + // The launcher mode answers about exactly one process, so the holder set below is what it + // does not need - and the Restart Manager call behind it is the slowest thing this tool + // does, which must not delay a wait that started with a ten-second grace. + if (mode == Mode::CloseLauncher) { + return closeLauncher(installDir); + } + // Processes 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 @@ -147,7 +232,6 @@ int main(int argumentCount, char **arguments) return EXIT_PROBE_FAILED; } - const Mode mode = requestedMode(argumentCount, arguments); if (holders.empty()) { // 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, From 9e50c5ad6e14194586849378d3fa0eb35f2ff03b Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:10:18 +0100 Subject: [PATCH 136/247] [Installer] Recover payload files when previous uninstaller is missing or fails to launch (#7682) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- cmake/NSIS.template.in | 119 +++++++++++++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 21 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index bc121ff25..ceba04877 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,10 +15,14 @@ Var ReinstallMode Var GuardDir Var OldUninstallString Var OldInstallDir -; What ExecWait reported for the previous version's uninstaller (empty when it could not be -; started at all). Read by WaitForOldPayloadToDisappear: an uninstaller that ran in place +; 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 @@ -397,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 @@ -421,10 +480,9 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall -StrCpy $R5 "" -ExecWait '$R0 _?=$OldInstallDir' $R5 -StrCpy $OldUninstallExit $R5 -!insertmacro CockLog "uninstall (32-bit): exit=$R5" +StrCpy $OldUninstallString $R0 +Call RunOldUninstaller +!insertmacro CockLog "uninstall (32-bit): exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -438,12 +496,11 @@ ${If} ${RunningX64} StrCpy $OldInstallDir $INSTDIR !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done64 - StrCpy $R5 "" DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$OldInstallDir' $R5 - StrCpy $OldUninstallExit $R5 - !insertmacro CockLog "uninstall (64-bit): exit=$R5" + StrCpy $OldUninstallString $R0 + Call RunOldUninstaller + !insertmacro CockLog "uninstall (64-bit): exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -471,6 +528,28 @@ 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 @@ -481,8 +560,9 @@ ${If} $R2 > 60 ; separate log; copy it here so the report sent from AbortInstall carries it. Call AppendUninstallerLog ${If} $OldUninstallExit != "0" - ; Cancelled, failed, or could not be started at all (empty code): the old version - ; was not meant to be removed, so nothing is deleted on its behalf. + ; 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 @@ -524,7 +604,8 @@ ${EndIf} Return FunctionEnd -; Called when the old uninstaller exited successfully but left its payload behind. +; 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 @@ -2046,10 +2127,8 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - StrCpy $R5 "" - ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 - StrCpy $OldUninstallExit $R5 - !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5" + Call RunOldUninstaller + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -2084,10 +2163,8 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - StrCpy $R5 "" - ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 - StrCpy $OldUninstallExit $R5 - !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5" + Call RunOldUninstaller + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall From ded83aaab55c205b86e7c06b44559b6480263ec6 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:01:16 +0100 Subject: [PATCH 137/247] [Installer] Ask a hidden instance to close, then offer to end it once (#7683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Request quit for hidden Cockatrice instances and record holders in abort telemetry * [Installer] Fix compiler warnings in holder probe and installer quit handler * [VisualDeckStorage] Order the includes the way clang-format wants them * [Installer] Ask a hidden instance to close, then offer to end it once The previous attempt had the client answer a registered window message, which cannot work for the update it was reported on: the instance being replaced is a client from before that message exists, so it never answers, and the user is left with "Cockatrice is still running" exactly as reported. Every step that can free the files has to be the installer's, so the client side is reverted. The probe now posts WM_CLOSE to the instance's own window whether that window is on screen or only in the notification area: the request does not need a visible window, an instance in the tray is the one this update has no other way to reach, and the confirmation Cockatrice answers with is a window Qt shows itself, so it is painted - unlike one restored from outside. A visible window is brought forward first, and Qt's bookkeeping windows stay excluded, because a close to the notification-area one terminates the instance without its confirmation. A question of the instance's own no longer suppresses the close. Withholding it protected nobody: the installer asked the user to close a program it had never asked, and a current client refuses the close while its confirmation is pending anyway, leaving the question where it is. The launcher mode keeps the visible-only rule, because a launcher without a window has already answered its own close and the wait ends it after a grace instead of asking a user who is not there. The guard asked the same question until the user gave up: after the automatic close failed it offered to end the programs, then re-ran the probe and showed the same dialog word for word when the answer was No, which is how an abort was reported three times over. It now asks once - Yes ends the Cockatrice, Oracle and Servatrice programs the payload installs and says what that costs, No cancels - and what survives an end is named in the abort instead of being put to the user as the same question again. The uninstaller's guard and the recovery path after a failed purge follow the same shape. Kept: the abort telemetry that records the holders, the close result and the end result as event data. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 189 +++++++++--------- cmake/SendInstallerLog.ps1.in | 26 +++ .../deck_preview_deck_tags_display_widget.cpp | 2 +- .../installer-holder-probe/close_holders.cpp | 110 +++++----- tools/installer-holder-probe/close_holders.h | 43 ++-- tools/installer-holder-probe/holder_line.h | 6 +- tools/installer-holder-probe/main.cpp | 12 +- 7 files changed, 223 insertions(+), 165 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index ceba04877..8d7a82f81 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -61,12 +61,8 @@ Var GuardOutput ; close, 1 after it. The prompt that follows offers to end the instance rather than asking ; a second time (see GuardAppsNotRunning). Var GuardCloseTried -; Set when the user answered Yes to ending the programs and the files were still in use -; afterwards, so the prompt that follows says the end did not help instead of reappearing -; word for word (see CockEndPrompt). -Var GuardEndFailed -; The question CockEndPrompt assembles: one sentence in it is conditional, and the result -; has to stay inside the characters one NSIS string holds. +; The question CockEndPrompt assembles: 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 @@ -1043,8 +1039,9 @@ FunctionEnd ; 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." -; The other half of that answer - what is left to do when nothing is ended - is shared by -; every prompt that names the holders, for the same reason. +; 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." ; usage: set the variable named by the second argument to the probe's listing, then call @@ -1056,23 +1053,22 @@ FunctionEnd ; 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. There is deliberately no +; "try again" - the same question, word for word, with nothing changed in between was what +; made an abort out of a stall. +; ; 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 and the sentence only a failed end adds, it stays inside -; that budget instead of being cut off at the tail, where the Cancel line is. +; 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 Cancel gives up, "installation" or "uninstallation" +; @param cancelWord what No gives up, "installation" or "uninstallation" !macro CockEndPrompt header listing cancelWord StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" - ; Said only after the previous answer was Yes: without it the same dialog reappears word - ; for word, which is what "nothing happened" looked like - the end can fail on a process - ; the installer may not open, and it never touches a program that is not one of ours. - ${If} $GuardEndFailed = 1 - StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nEnding them did not free the files." - ${EndIf} - StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to try again after closing anything else listed above by hand. ${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nChoose Cancel to cancel the ${cancelWord}." + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" !macroend ; Emitted once for the installer and once for the uninstaller: the uninstaller @@ -1115,10 +1111,12 @@ FunctionEnd ; 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 brought to the foreground before WM_CLOSE is posted to its main window. -; WM_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. +; Each instance's own window is asked to close with WM_CLOSE - whether it is on screen or +; only in the notification area, since the request does not need a visible window and the +; notification-area instance is the one this update has no other way to reach. A visible +; window is brought forward first. WM_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 @@ -1171,14 +1169,16 @@ FunctionEnd ; usage: set $GuardDir to the directory to guard, then call this. ; -; Asks Cockatrice to close once, then prompts until the payload is free. 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 Cockatrice may still be waiting for an answer in -; its own confirmation. It offers to end the programs the payload installs - and only those, -; which the text says - with what that costs (Yes), a plain re-check for after the user -; closed things by hand (No), and Cancel - ending is the last resort, see EndHolders. +; 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 @@ -1188,7 +1188,6 @@ FunctionEnd Function GuardAppsNotRunning !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" StrCpy $GuardCloseTried 0 - StrCpy $GuardEndFailed 0 guard_check: Call RunHolderProbe ${If} $GuardExit == "0" @@ -1211,21 +1210,15 @@ Function GuardAppsNotRunning Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; The automatic close already ran once. Asking Cockatrice to close again is unlikely - ; to help: an instance that did not answer the first one is usually sitting on a - ; confirmation of its own, and the close probe leaves such an instance alone so that a - ; second WM_CLOSE cannot hide the window the question is in - which is how a pre-3.1.2 - ; update used to leave a headless process behind. An instance with no window has - ; nothing to ask at all. Ending is what is left that can free the files, so the prompt - ; offers it here, with what it costs in the text: an unsaved deck, a game, a settings - ; write, and an Oracle that may be mid-way through the card database. The answers that - ; end nothing stay reachable from the same dialog: the instance's own confirmation, - ; and closing anything by hand. + ; The close ran and did not free the files, so ending them is the only thing left that + ; can - and that is the one question, asked once. Its other answer stops the run + ; instead of asking again: a second identical dialog, with nothing changed in between, + ; is what made users cancel an update the installer could have finished by itself. !insertmacro CockEndPrompt "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_check + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ - "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." + "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 @@ -1234,12 +1227,25 @@ Function GuardAppsNotRunning guard_end: !insertmacro CockLog "guard: ending the payload programs on request, then checking again" Call EndHolders - ; Everything the payload installs is ended, but a program that could not be opened - ; (another account, a protected process) survives, and a program that is not one of - ; ours is never touched at all, so what is left may still have to be closed by hand. - ; Go back to the same prompt, which says the end did not free the files. - StrCpy $GuardEndFailed 1 - Goto guard_check + ; 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} + !insertmacro CockLog "guard: still in use after ending them ($GuardExit): $GuardOutput" + ${If} $GuardExit == "1" + StrCpy $AbortReason \ + "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} + Call RestoreUserDataAfterUninstall + Call AbortInstall ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager ; call failed and nothing runs from the directory, or it timed out. Carrying @@ -1921,36 +1927,20 @@ FunctionEnd ; 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. ; -; Every round offers all three answers: Yes ends the programs the payload installs - and -; says so, because anything else the probe named is left to the user - and purges again, No -; purges again without ending anything (for when the user closed things by hand), and -; Cancel gives up. Each round re-reads the holders, so the prompt names what survived the -; last attempt rather than repeating the first listing. +; 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} - StrCpy $GuardEndFailed 0 ; 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 - Goto rfh_retry - rfh_prompt: - ; The same question the guard asks, so the two cannot drift apart in what they promise. - !insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation" - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end IDNO rfh_retry - !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 - ; The next prompt says the end did not free the files, instead of naming the same - ; survivors word for word. - StrCpy $GuardEndFailed 1 - rfh_retry: Call RetryDllPurge StrCpy $StaleRuntimeAfterPurge $R0 !insertmacro CockLogListing "after-retry-purge" "*.dll" @@ -1960,13 +1950,33 @@ Function RecoverFromHolders DetailPrint "Files of the previous version were removed; continuing the installation." Return ${EndIf} - ; Still stuck: refresh the names, so the next prompt and the log describe what - ; survived the last attempt and not what was there before it. + ; 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} - Goto rfh_prompt + ; 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 @@ -1980,7 +1990,6 @@ FunctionEnd Function un.GuardAppsNotRunning StrCpy $GuardCloseTried 0 - StrCpy $GuardEndFailed 0 un_guard_check: !insertmacro CockLogUn "uninst-guard: checking $GuardDir" Call un.RunHolderProbe @@ -1999,15 +2008,12 @@ Function un.GuardAppsNotRunning Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; Same approach as in the installer's guard: the automatic close already ran once, and - ; asking again is unlikely to help an instance that is sitting on its own confirmation - ; or has no window a close could reach. Ending is what is left that can free the files, - ; so the prompt offers it here, with what it costs in the text, while keeping the - ; answers that end nothing - the instance's own question, and closing anything by hand - ; - reachable from the same dialog. CockEndPrompt words it, so this copy and the + ; Same question as in the installer's guard, and the same two answers: Yes ends the + ; programs the payload installs, anything else stops the uninstallation. Asking again + ; after a No only repeated the dialog. CockEndPrompt words it, so this copy and the ; installer's promise the same thing. !insertmacro CockEndPrompt "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_check + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end !insertmacro CockLogUn "uninst-guard: cancelled by user" SetErrorLevel 1 Abort @@ -2015,12 +2021,17 @@ Function un.GuardAppsNotRunning un_guard_end: !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again" Call un.EndHolders - ; Everything the payload installs is ended, but a program that could not be opened - ; (another account, a protected process) survives, and a program that is not one of - ; ours is never touched at all, so what is left may still have to be closed by hand. - ; Go back to the same prompt, which says the end did not free the files. - StrCpy $GuardEndFailed 1 - Goto un_guard_check + ; 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 @@ -2333,8 +2344,8 @@ ${If} $StaleRuntimeAfterPurge = 1 ${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 or to try the removal again; this updates $StaleRuntimeAfterPurge - ; with the last retry's verdict. + ; 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 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/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 1a06c0b18..11c8df54a 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 @@ -12,9 +12,9 @@ #include #include -#include #include #include +#include #include #include #include diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index d1831e4e9..53a1fed8e 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -103,44 +103,50 @@ bool isAnswerableWindow(HWND window) /// Picks the window to post the close to and the one the user should end up looking at. struct CloseTarget { - HWND main = nullptr; ///< first visible window without an owner: the application's own + 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 modalOpen = false; ///< the only window a user could answer is the instance's own question + bool hidden = false; ///< the window being closed is not on screen (notification area) }; -/// Only the windows the user could answer are candidates - see isAnswerableWindow. An -/// instance left with nothing but Qt's own bookkeeping windows visible has no window to ask: -/// the caller reports that instead of posting a close the application would answer with a -/// terminate that skips its own confirmation. -CloseTarget chooseCloseTarget(const std::vector &windows) +/// 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; - HWND topmost = nullptr; for (const HWND window : windows) { - if (IsWindowVisible(window) == FALSE || !isAnswerableWindow(window)) { + if (!isAnswerableWindow(window)) { continue; } - if (topmost == nullptr) { - topmost = window; + const bool visible = IsWindowVisible(window) != FALSE; + if (visible && target.toRaise == nullptr) { + target.toRaise = window; } - if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr && (visible || askHidden)) { target.main = window; + target.hidden = !visible; } } - target.toRaise = topmost != nullptr ? topmost : target.main; - // A modal dialog disables the windows behind it, so a disabled main window is the - // instance asking a question of its own. A visible window with no unowned main window - // next to it is one too: what is on screen is then the question itself - the window - // behind it is hidden, not gone - and there is no window a close could be posted to - // without hiding the question with it. - target.modalOpen = target.toRaise != nullptr && (target.main == nullptr || IsWindowEnabled(target.main) == FALSE); 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 that needs the next close to bring it back into view. -std::wstring describeWindowState(HWND window) +/// 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"; @@ -151,7 +157,12 @@ std::wstring describeWindowState(HWND window) DWORD_PTR ignored = 0; const bool responding = SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &ignored) != 0; - std::wstring state = IsWindowVisible(window) != FALSE ? L", window still open" : L", window hidden again"; + 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"; } @@ -214,13 +225,15 @@ bool isCockatriceImage(const std::wstring &imageName) return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; } -CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +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)) { @@ -233,38 +246,36 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un WindowSearch search; search.processId = holder.processId; EnumWindows(collectProcessWindow, reinterpret_cast(&search)); - if (search.windows.empty()) { + CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows); + if (target.main == nullptr) { + // Nothing of the application's own to post a close to: every window left is one of + // Qt's bookkeeping windows, or the only thing on screen is a question whose owner + // window is gone. Neither is a close the application would answer - a close to the + // notification-area window terminates without the confirmation - so the instance is + // named and the guard's end answer is what can free its files. ++result.withoutWindow; - lines.push_back(processLabel(holder) + L" has no window to close, left to you"); + lines.push_back(processLabel(holder) + L" has no window of its own to close"); continue; } - const CloseTarget target = chooseCloseTarget(search.windows); - if (target.toRaise == nullptr) { - // Nothing visible that a user could answer: the instance is hidden - in the - // notification area, say - or has nothing but Qt's own bookkeeping windows on - // screen. Neither is a window a close could reach, and showing one from here does - // not help: Windows would display it while the application still believes it is - // hidden, so it stays blank and answers nothing. The instance is named and left to - // the user, who can open it from the notification area. - ++result.withoutWindow; - lines.push_back(processLabel(holder) + L" has no window to ask, left to you"); - continue; - } std::wstring line = processLabel(holder); - line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; - - // Posting the close to an instance that is asking a question of its own is what made - // an earlier attempt useless: the client accepts it, hides this window and the dialog - // with it, and stays alive inside the nested loop the dialog opened, where nothing can - // reach it any more. The question is the request now, so it is brought forward and - // left alone - including a question whose owner window is hidden, which is the only - // thing left on screen and has no unowned window a close could be posted to. - if (target.modalOpen) { - lines.push_back(line + L", and is waiting for an answer to a question of its own"); - continue; + 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"; } + // 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. + // // 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); @@ -287,6 +298,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un 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)); } @@ -300,7 +312,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms"; } else { lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" + - describeWindowState(askedWindows[index]); + describeWindowState(askedWindows[index], askedWasHidden[index]); } } } diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 643624779..91e6b316b 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -10,15 +10,15 @@ * @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 and in the second prompt. - * @c withoutWindow counts the holders that had no visible window a user could answer: 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. + * 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 a WM_CLOSE was posted to - unsigned int withoutWindow = 0; ///< holders with no visible window a user could answer + unsigned int withoutWindow = 0; ///< holders with no window of their own to ask std::wstring report; ///< one line, narrowed to ASCII when written }; @@ -42,27 +42,34 @@ bool isCockatriceImage(const std::wstring &imageName); * 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: its main window is brought to the foreground, because - * Cockatrice may answer the close with a confirmation of its own that would otherwise open - * behind the installer's prompt. Then WM_CLOSE is posted to the main window, the same - * request as clicking its close button - unless the instance is asking a question of its - * own, which disables that window: posting the close then only hides the question, so it is - * brought forward and left to the user. An instance with no window a user could answer - - * one hidden in the notification area, say - is named and left to the user as well; showing - * its windows from here does not help, because the application would still believe they are - * hidden and never paint them. Stopping the process is never attempted. + * One thing happens per instance: its own window is asked to close with WM_CLOSE, the same + * request as clicking that window's close button - whether it 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 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, 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. + * 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); +CloseHoldersResult +closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds, bool askHiddenWindows); /** * @brief What ending the holders did, in the form the installer logs. diff --git a/tools/installer-holder-probe/holder_line.h b/tools/installer-holder-probe/holder_line.h index 163112ff6..675d79fc6 100644 --- a/tools/installer-holder-probe/holder_line.h +++ b/tools/installer-holder-probe/holder_line.h @@ -29,9 +29,9 @@ inline constexpr std::size_t HOLDER_LINE_MAX = 5; * 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 that also says the last end - * freed nothing, plus the "; and N more" the summary appends after the budget - complete, - * including the Cancel line at its end. Windows caps one application or service name well + * so this budget is what keeps the longest message - the one with the longest listing, plus + * the "; and N more" the summary appends after the budget - complete, including the sentence + * that offers the other answer at its end. Windows caps one application or service name well * below this, so it bounds the list without ever cutting a single holder short. */ inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 320; diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 10a6c22e4..d88760b35 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -148,9 +148,9 @@ bool launcherProcessIdFromEnvironment(unsigned long &processId) /// /// 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 -/// window to ask is past its own prompts and stuck in shutdown, so no answer the installer -/// offers would ever reach it. Everything else - a window that was asked, or one that is -/// waiting on a question of its own - is still the user's decision. +/// 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; @@ -170,7 +170,7 @@ int closeLauncher(const std::wstring &installDir) writeLine(L"the launcher is not cockatrice.exe, left to you"); return EXIT_PROBE_FAILED; } - const CloseHoldersResult result = closeCockatriceHolders({holder}, CLOSE_WAIT_MILLISECONDS); + const CloseHoldersResult result = closeCockatriceHolders({holder}, CLOSE_WAIT_MILLISECONDS, false); writeLine(result.report); if (result.askedToClose > 0) { return EXIT_HOLDERS_FOUND; @@ -251,7 +251,9 @@ int main(int argumentCount, char **arguments) return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; } - const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS); + // 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; } From 0369aaffa973b5820d3b6270a8eceadaf94bc9c5 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:16:50 +0100 Subject: [PATCH 138/247] [Installer] Name only the payload programs installed in the directory (#7684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The running-instance lookup named every process whose image path began with the install directory, subfolders included. Installing into a folder that holds another application in a subfolder therefore reported that application as holding the previous version, and nothing the guard offers could clear it: the close asks cockatrice.exe only, and the end answer refuses anything that is not a payload program. Both answers were no-ops, so the update could only abort and tell the user to close a program that was never touching the files. Name only the programs the payload installs, and only when the image lies in the install directory itself. A program below the directory that really does hold a payload file is still named by the Restart Manager lookup, which is the lookup for that question. The Windows probe check now covers a payload-named program in a subfolder and a program the payload does not install; both have to come back as nothing. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 48 ++++++++++++++-- cmake/NSIS.template.in | 43 +++++++------- tests/CMakeLists.txt | 14 ++++- tests/payload_paths_test.cpp | 57 +++++++++++++++++++ tools/installer-holder-probe/CMakeLists.txt | 4 +- .../installer-holder-probe/close_holders.cpp | 1 + tools/installer-holder-probe/main.cpp | 22 +++---- .../installer-holder-probe/payload_paths.cpp | 52 +++++++++++++++++ tools/installer-holder-probe/payload_paths.h | 39 +++++++++++++ .../restart_manager_holders.cpp | 53 +++++++---------- .../restart_manager_holders.h | 35 +++++------- 11 files changed, 279 insertions(+), 89 deletions(-) create mode 100644 tests/payload_paths_test.cpp create mode 100644 tools/installer-holder-probe/payload_paths.cpp create mode 100644 tools/installer-holder-probe/payload_paths.h diff --git a/.ci/compile.sh b/.ci/compile.sh index 1a30a0fd3..cea837528 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -567,8 +567,9 @@ Start-Sleep -Seconds 120 # 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. - $copy = Join-Path $dir "fake-cockatrice.exe" + # 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 { @@ -580,10 +581,49 @@ Start-Sleep -Seconds 120 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 "fake-cockatrice\.exe \(pid\s+$($running.Id)") { + if ($byImage.Output -notmatch "cockatrice\.exe \(pid\s+$($running.Id)") { throw "the lookup did not name the process running from the directory: $($byImage.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, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" + + # 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, 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/cmake/NSIS.template.in b/cmake/NSIS.template.in index 8d7a82f81..d7a6e82c6 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -998,7 +998,8 @@ FunctionEnd ; 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 -; process whose image lives in the directory being replaced. +; payload program whose image lies in the directory being replaced rather than +; in a folder below it. ; ; 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 @@ -1084,10 +1085,10 @@ FunctionEnd ; 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 nothing runs from the directory, 1 = the +; 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 = the Restart Manager call failed and no process runs -; from the directory either, "timeout" = it did not return in time). +; directory was given, 3 = 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 @@ -1106,7 +1107,7 @@ FunctionEnd ; 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 processes running from the directory. tools\installer-holder-probe +; 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. @@ -1248,14 +1249,15 @@ Function GuardAppsNotRunning Call AbortInstall ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager - ; call failed and nothing runs from the directory, or it 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), Ignore installs anyway, Abort cancels - ; instead of writing a payload next to files the guard could not rule out. + ; call failed and no payload program runs from the directory, or it 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), 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 check whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ @@ -1862,12 +1864,13 @@ FunctionEnd ; 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 process running from the directory, 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; then the per-file codes logged -; above are the whole story, and the probe says so by naming nothing rather than by -; failing. +; 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; then the per-file +; 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 guard ; uses, so a path with spaces or apostrophes never has to be embedded in a command line. @@ -1889,7 +1892,7 @@ Function LogProcessesUsingInstallDir ${If} $HolderExit == "1" StrCpy $HolderFound 1 ${ElseIf} $HolderExit == "0" - StrCpy $HolderOutput "nothing is holding them and no program is running from the directory" + 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" ${EndIf} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 62b735a50..1750a448e 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 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/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index e4def8c39..e8ea6df16 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -5,7 +5,9 @@ # 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 main.cpp restart_manager_holders.cpp) +add_executable( + installer-holder-probe close_holders.cpp holder_line.cpp main.cpp payload_paths.cpp restart_manager_holders.cpp +) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 53a1fed8e..7c599bdec 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -1,5 +1,6 @@ #include "close_holders.h" +#include "payload_paths.h" #include "restart_manager_holders.h" // clang-format off diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index d88760b35..f9ca84ac8 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -34,7 +34,7 @@ constexpr unsigned long END_WAIT_MILLISECONDS = 5000; /// 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 only for -/// a lookup that failed and named nobody: one that failed but still named a process +/// a lookup that failed and named nobody: one that failed but still named a payload program /// running from the directory is answered with that process. 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. @@ -61,11 +61,13 @@ enum ExitCode /// no window has answered its own close and is stuck in shutdown /// /// The guard's three modes answer from two lookups that cover each other's blind spot: the -/// Restart Manager names whoever has a payload file open, and every process running from the -/// directory 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. Neither lookup alone is the guard's authority: an instance the file -/// handles do not name is exactly the case that left a half-replaced install behind. +/// Restart Manager names whoever has a payload file open, and 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. Neither lookup alone is the guard's authority: an instance the +/// file handles do not name is exactly the case that left a half-replaced install behind. +/// 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 @@ -201,8 +203,8 @@ int main(int argumentCount, char **arguments) return closeLauncher(installDir); } - // Processes 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 + // 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 @@ -225,8 +227,8 @@ int main(int argumentCount, char **arguments) } // A failed lookup only makes the probe unable to answer when it also named nobody: with - // a process running from the directory the installer still knows what to close, and - // that is worth more to the guard than the code of the query that missed it. + // a payload program running from the directory the installer still knows what to close, + // and that is worth more to the guard than the code of the query that missed it. if (error != ERROR_SUCCESS && holders.empty()) { writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); return EXIT_PROBE_FAILED; diff --git a/tools/installer-holder-probe/payload_paths.cpp b/tools/installer-holder-probe/payload_paths.cpp new file mode 100644 index 000000000..8fac40040 --- /dev/null +++ b/tools/installer-holder-probe/payload_paths.cpp @@ -0,0 +1,52 @@ +#include "payload_paths.h" + +#include +#include +#include + +namespace +{ +/// The payload programs, by image name. A process running one of these is what the guard +/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. +constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; + +/// 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..247fc64ed --- /dev/null +++ b/tools/installer-holder-probe/payload_paths.h @@ -0,0 +1,39 @@ +#ifndef PAYLOAD_PATHS_H +#define PAYLOAD_PATHS_H + +#include + +/** + * @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 e8c982f26..e3fb3e3c8 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -30,10 +30,6 @@ constexpr int MAX_ASKER_LEVELS = 8; /// The payload files the installer replaces by name; the DLLs are matched by mask below. constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; -/// The payload programs, by image name. A process running one of these is what the guard -/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. -constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; - void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -84,15 +80,9 @@ std::wstring imagePathOf(unsigned long processId) return imagePath; } -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); -} - -/// @p directory with exactly one trailing separator, so that a sibling whose name merely -/// begins with the same letters ("Cockatrice-old" next to "Cockatrice") cannot pass for a -/// subdirectory of it. +/// @p directory with exactly one trailing separator, so that two spellings of the same +/// directory compare equal and a sibling whose name merely begins with the same letters +/// ("Cockatrice-old" next to "Cockatrice") cannot pass for it. std::wstring directoryPrefixOf(const std::wstring &directory) { std::wstring prefix = directory; @@ -103,15 +93,17 @@ std::wstring directoryPrefixOf(const std::wstring &directory) return prefix; } -/// Whether @p path lies inside @p prefix, which is a directory with a trailing separator. -/// Ordinal and case-insensitive, as Windows compares paths. -bool isUnderDirectory(const std::wstring &path, const std::wstring &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 (path.size() < prefix.size()) { + if (left.empty() || right.empty()) { return false; } - const int length = static_cast(prefix.size()); - return CompareStringOrdinal(path.c_str(), length, prefix.c_str(), length, TRUE) == CSTR_EQUAL; + 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. @@ -134,16 +126,6 @@ Holder holderOf(const RM_PROCESS_INFO &info) } } // namespace -bool isPayloadProgram(const std::wstring &imageName) -{ - for (const wchar_t *program : PAYLOAD_PROGRAMS) { - if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { - return true; - } - } - return false; -} - std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; @@ -279,7 +261,6 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) return holders; } - const std::wstring prefix = directoryPrefixOf(installDir); // 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). @@ -300,12 +281,20 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) // 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); - if (!isUnderDirectory(imagePath, prefix)) { + 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 = fileNameOf(imagePath); + holder.imageName = imageName; holders.push_back(std::move(holder)); } while (Process32NextW(snapshot, &entry) != FALSE); } diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index c14499941..e966b70b8 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -2,6 +2,7 @@ #define RESTART_MANAGER_HOLDERS_H #include "holder_line.h" +#include "payload_paths.h" #include #include @@ -21,18 +22,6 @@ */ std::vector collectPayloadFiles(const std::wstring &installDir); -/** - * @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 ends the - * same set it names. Matched case-insensitively on the image name alone: only holders are - * ever tested against it, and a holder is a process that has a payload file open or runs - * from the install directory. - * - * @param imageName the file name of a process image, empty when it could not be read. - */ -bool isPayloadProgram(const std::wstring &imageName); - /** * @brief Names the applications and services that have one of @p files open or mapped. * @@ -47,8 +36,7 @@ bool isPayloadProgram(const std::wstring &imageName); unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); /** - * @brief Lists every process whose image lives in @p installDir, whether or not it has a - * payload file open. + * @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 @@ -57,16 +45,23 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st * 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, so a process that merely shares a name (an Oracle - * database server next to a Cockatrice install) is never named. + * 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 process running from it, in snapshot order, without the processes - * named by askingProcessIds(); empty when the directory is empty or no process - * runs from it. + * @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); From 720adf5410faf25431df77220a85bc9d8998594b Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 31 Oct 2026 09:28:52 +0100 Subject: [PATCH 139/247] [Settings] Rename UI language selector & move translator hint (#7656) * Update general_settings_page.cpp * lint --- .../settings_page/general_settings_page.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 77abc699c..7b89b744d 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -100,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); @@ -568,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( From bbc7e462ff0e9fd5391a0626e6f46fc79223aad5 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:59 +0100 Subject: [PATCH 140/247] [Installer] Graceful close and a way out of a stuck update (#7687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Stop calling the payload free while an executable is locked The guard answered "nothing is holding the payload" from two lookups that can both miss a running instance: 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 the probe at all. A user log showed exactly that: the first guard passed while cockatrice.exe was running from the directory, the old uninstaller silently skipped the file it could not delete, and the update aborted half an uninstall later with "cancelled by user" after a close attempt the running window ignored. Ask the file itself. A running image is opened without write sharing, so the write open replacing it needs is refused with ERROR_SHARING_VIOLATION for as long as the program runs, while a file that is merely being read still accepts it. When neither lookup named anybody and a payload executable refuses that open, the probe answers that the file is in use and that the program holding it could not be identified - the guard's "could not confirm" dialog, whose Retry works once the user has closed it - instead of passing the payload as free. Only the payload executables are tested, not every file the purge removes: a locked executable proves a program is running and a mapped DLL belongs to a running executable, while a locked DLL or qt.conf can be the asking process itself, which is the loop the guard already had. The open is retried once after 300 ms, and only for the files that refused, so a scanner holding a file for a moment cannot stop an install for nothing. The Windows probe check now covers that answer: a payload executable held by the asking process, so that it cannot be named, has to come back as in use rather than as free. Co-authored-by: Lukas Brübach * [Installer] Close running clients over a local socket The installer could only ask a running client to close with WM_CLOSE, which an instance hidden in the notification area or sitting on its own confirmation cannot answer usefully. Give every client a per-process local socket and have the holder probe ask it there first, falling back to WM_CLOSE for clients from before the socket. * [Installer] Offer to check again or update by hand when files stay locked Replace the two-answer guard prompt with force close, close by hand and check again, or cancel. Retry the automatic close while the set of holders changes, and point a cancelled run at the latest release so a user who does not want anything killed has a way forward that is not cancelling the update again. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 25 +- cmake/NSIS.template.in | 231 ++++++++++++------ cockatrice/CMakeLists.txt | 2 + cockatrice/src/installer_close_server.cpp | 81 ++++++ cockatrice/src/installer_close_server.h | 50 ++++ cockatrice/src/interface/window_main.cpp | 24 ++ cockatrice/src/interface/window_main.h | 11 + cockatrice/src/main.cpp | 10 + libcockatrice_utility/CMakeLists.txt | 1 + .../utility/installer_close_protocol.h | 18 ++ tests/CMakeLists.txt | 16 ++ tests/installer_close_server_test.cpp | 101 ++++++++ tools/installer-holder-probe/CMakeLists.txt | 14 +- .../installer-holder-probe/close_holders.cpp | 63 +++-- tools/installer-holder-probe/close_holders.h | 25 +- .../installer_close_name.cpp | 12 + .../installer_close_name.h | 24 ++ .../installer_close_request.cpp | 96 ++++++++ .../installer_close_request.h | 21 ++ tools/installer-holder-probe/main.cpp | 73 ++++-- .../installer-holder-probe/payload_locks.cpp | 67 +++++ tools/installer-holder-probe/payload_locks.h | 36 +++ .../installer-holder-probe/payload_paths.cpp | 4 - tools/installer-holder-probe/payload_paths.h | 12 + .../restart_manager_holders.cpp | 10 +- 25 files changed, 897 insertions(+), 130 deletions(-) create mode 100644 cockatrice/src/installer_close_server.cpp create mode 100644 cockatrice/src/installer_close_server.h create mode 100644 libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h create mode 100644 tests/installer_close_server_test.cpp create mode 100644 tools/installer-holder-probe/installer_close_name.cpp create mode 100644 tools/installer-holder-probe/installer_close_name.h create mode 100644 tools/installer-holder-probe/installer_close_request.cpp create mode 100644 tools/installer-holder-probe/installer_close_request.h create mode 100644 tools/installer-holder-probe/payload_locks.cpp create mode 100644 tools/installer-holder-probe/payload_locks.h diff --git a/.ci/compile.sh b/.ci/compile.sh index cea837528..bf5a169af 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -559,6 +559,29 @@ Start-Sleep -Seconds 120 } 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 @@ -623,7 +646,7 @@ Start-Sleep -Seconds 120 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, 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" + 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/cmake/NSIS.template.in b/cmake/NSIS.template.in index d7a6e82c6..923aded9d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -57,12 +57,16 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; Whether the guard has already asked Cockatrice to close by itself: 0 before the automatic -; close, 1 after it. The prompt that follows offers to end the instance rather than asking -; a second time (see GuardAppsNotRunning). -Var GuardCloseTried -; The question CockEndPrompt assembles: fixed sentences around the probe's line, and the -; result has to stay inside the characters one NSIS string holds. +; 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 @@ -1029,11 +1033,14 @@ FunctionEnd ; 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 (WM_CLOSE), made automatically once because that is what -; the user started the installer to do, and only Cockatrice can answer one - the other -; two have no window. When the close is not answered the user is told which program is -; in the way and is offered to end all three themselves - the last resort, with what it -; costs spelled out in the prompt. +; 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. ; 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 @@ -1045,9 +1052,15 @@ FunctionEnd ; 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, the uninstaller's guard and the -; recovery path all ask. +; 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 @@ -1055,9 +1068,9 @@ FunctionEnd ; 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. There is deliberately no -; "try again" - the same question, word for word, with nothing changed in between was what -; made an abort out of a stall. +; 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 @@ -1069,7 +1082,29 @@ FunctionEnd ; @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 end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" + 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 @@ -1087,8 +1122,9 @@ FunctionEnd ; $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 = the Restart Manager call failed and no payload program -; runs from the directory either, "timeout" = it did not return in time). +; 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 @@ -1112,12 +1148,13 @@ FunctionEnd ; 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's own window is asked to close with WM_CLOSE - whether it is on screen or -; only in the notification area, since the request does not need a visible window and the -; notification-area instance is the one this update has no other way to reach. A visible -; window is brought forward first. WM_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. +; 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 @@ -1188,7 +1225,9 @@ FunctionEnd ; that has nothing to report stays silent. Function GuardAppsNotRunning !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" - StrCpy $GuardCloseTried 0 + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" guard_check: Call RunHolderProbe ${If} $GuardExit == "0" @@ -1198,26 +1237,40 @@ Function GuardAppsNotRunning ${EndIf} ${If} $GuardExit == "1" !insertmacro CockLog "guard: still in use by $GuardOutput" - ; Close it for the user first. The probe knows exactly which cockatrice.exe holds - ; these files, and asking it to close is the same request as clicking the window's - ; close button - the application still answers its own "really quit?" prompt, so - ; nothing is lost. One attempt per guard call keeps the common case (the client was - ; still shutting down) from turning into a question, without hiding the case where - ; the close did not help: the pass after it names what is still holding the files. - ${If} $GuardCloseTried = 0 - StrCpy $GuardCloseTried 1 - !insertmacro CockLog "guard: asking cockatrice to close, then checking again" + ; 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, so ending them is the only thing left that - ; can - and that is the one question, asked once. Its other answer stops the run - ; instead of asking again: a second identical dialog, with nothing changed in between, - ; is what made users cancel an update the installer could have finished by itself. - !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end - !insertmacro CockLog "guard: cancelled by user" + ; 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; @@ -1249,25 +1302,27 @@ Function GuardAppsNotRunning Call AbortInstall ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager - ; call failed and no payload program runs from the directory, or it 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), Ignore installs anyway, Abort - ; cancels instead of writing a payload next to files the guard could not - ; rule out. + ; 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 check whether Cockatrice is still running: $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "This installer could not check 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." \ + "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 check 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." + "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 @@ -1292,7 +1347,18 @@ FunctionEnd Function AbortInstall !insertmacro CockLog "ABORT: $AbortReason" !insertmacro CockLog "full installer log: $InstallLogPath" - MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + !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 @@ -1868,9 +1934,9 @@ FunctionEnd ; 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; then the per-file -; codes logged above are the whole story, and the probe says so by naming nothing rather -; than by failing. +; is a handle taken in kernel mode, where there is no process to name; a payload executable +; among the files then still refuses to be replaced, and the probe answers with that file +; instead of naming nothing. ; ; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard ; uses, so a path with spaces or apostrophes never has to be embedded in a command line. @@ -1894,7 +1960,7 @@ Function LogProcessesUsingInstallDir ${ElseIf} $HolderExit == "0" StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory" ${Else} - StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" + StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput" ${EndIf} !insertmacro CockLog "holders: $HolderOutput" FunctionEnd @@ -1992,7 +2058,9 @@ Function un.EnsureAppsNotRunning FunctionEnd Function un.GuardAppsNotRunning - StrCpy $GuardCloseTried 0 + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" un_guard_check: !insertmacro CockLogUn "uninst-guard: checking $GuardDir" Call un.RunHolderProbe @@ -2002,24 +2070,36 @@ Function un.GuardAppsNotRunning ${EndIf} ${If} $GuardExit == "1" !insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput" - ; Same convenience as in the installer: ask Cockatrice to close once, then report - ; what the close could not reach. - ${If} $GuardCloseTried = 0 - StrCpy $GuardCloseTried 1 - !insertmacro CockLogUn "uninst-guard: asking cockatrice to close, then checking again" + ; 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 as in the installer's guard, and the same two answers: Yes ends the - ; programs the payload installs, anything else stops the uninstallation. Asking again - ; after a No only repeated the dialog. CockEndPrompt words it, so this copy and the - ; installer's promise the same thing. - !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end - !insertmacro CockLogUn "uninst-guard: cancelled by user" + ; 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" @@ -2039,11 +2119,12 @@ Function un.GuardAppsNotRunning ; 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 can simply be retried. + ; 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 check whether Cockatrice is still running: $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "This uninstaller could not check 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." \ + "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 diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bec858dcb..4bd1bde56 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -345,6 +345,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 +451,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 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/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a44176f38..f8ab5d3f6 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -986,6 +986,30 @@ bool MainWindow::closeForUpdate() 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 98ef6a9c1..9e342e4b0 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -255,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_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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1750a448e..19f9268ee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -445,6 +445,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( 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/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index e8ea6df16..99e7d5045 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -6,11 +6,23 @@ # 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 main.cpp payload_paths.cpp restart_manager_holders.cpp + 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 index 7c599bdec..24fe7f0e7 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -1,5 +1,6 @@ #include "close_holders.h" +#include "installer_close_request.h" #include "payload_paths.h" #include "restart_manager_holders.h" @@ -248,19 +249,21 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil search.processId = holder.processId; EnumWindows(collectProcessWindow, reinterpret_cast(&search)); CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows); - if (target.main == nullptr) { - // Nothing of the application's own to post a close to: every window left is one of - // Qt's bookkeeping windows, or the only thing on screen is a question whose owner - // window is gone. Neither is a close the application would answer - a close to the - // notification-area window terminates without the confirmation - so the instance is - // named and the guard's end answer is what can free its files. + 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.hidden) { + 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) { @@ -269,19 +272,40 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; } - // 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. - // // 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; - if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) { + + // 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) { @@ -290,8 +314,9 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil 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"; } - 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")"; @@ -311,6 +336,10 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil 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]); diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 91e6b316b..3f6c889fd 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -17,7 +17,7 @@ */ struct CloseHoldersResult { - unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to + 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 }; @@ -42,16 +42,19 @@ bool isCockatriceImage(const std::wstring &imageName); * 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: its own window is asked to close with WM_CLOSE, the same - * request as clicking that window's close button - whether it 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 is named and left to the guard, which offers to end it. - * Stopping the process is never attempted here. + * 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 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 f9ca84ac8..31901a0ec 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -1,5 +1,7 @@ #include "close_holders.h" #include "holder_line.h" +#include "payload_locks.h" +#include "payload_paths.h" #include "restart_manager_holders.h" #include @@ -33,11 +35,12 @@ 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 only for -/// a lookup that failed and named nobody: one that failed but still named a payload program -/// running from the directory is answered with that process. 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. +/// 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, @@ -60,12 +63,15 @@ enum ExitCode /// 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 two lookups that cover each other's blind spot: the -/// Restart Manager names whoever has a payload file open, and every payload program running +/// 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. Neither lookup alone is the guard's authority: an instance the -/// file handles do not name is exactly the case that left a half-replaced install behind. +/// 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 @@ -109,6 +115,24 @@ void writeLine(const std::wstring &text) WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); } +/// The line for a payload executable that is in use while no lookup could name the program +/// holding it: the answer the guard shows, so it names the file the user has to free and +/// says that the program doing it is still to be closed by hand. +std::wstring inUseLine(const std::vector &files) +{ + std::wstring names; + for (const std::wstring &file : files) { + if (!names.empty()) { + names += L", "; + } + names += fileNameOf(file); + } + if (files.size() == 1) { + return names + L" is in use and the program holding it could not be identified"; + } + return names + L" are in use and the programs holding them could not be identified"; +} + std::wstring environmentVariable(const wchar_t *name) { for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { @@ -226,15 +250,30 @@ int main(int argumentCount, char **arguments) } } - // A failed lookup only makes the probe unable to answer when it also named nobody: with - // a payload program running from the directory the installer still knows what to close, - // and that is worth more to the guard than the code of the query that missed it. - if (error != ERROR_SUCCESS && holders.empty()) { - writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); - return EXIT_PROBE_FAILED; - } - + // Nobody was named. That is not yet the answer that nothing holds the payload: a lookup + // that failed only makes the probe unable to answer when it also named nobody, and a + // payload executable that refuses to be replaced is running even when neither lookup + // could name it. if (holders.empty()) { + // Neither process lookup is proof that the payload is free: the Restart Manager does + // not report every mapping, and a program running under another account cannot be + // read from here, so "nobody was named" is not the answer "nothing is running". The + // file cannot be missed - a running image refuses the write the replacement needs - + // and an answer of "nothing is holding them" over that locked file is what sent + // updates into an uninstall that could only fail halfway. Reported as a check that + // could not name what it found, so the guard asks the user to close it and retry + // instead of replacing a payload that is still running. + const std::vector inUse = payloadExecutablesInUse(installDir); + if (!inUse.empty()) { + writeLine(inUseLine(inUse)); + return EXIT_PROBE_FAILED; + } + + if (error != ERROR_SUCCESS) { + writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); + return EXIT_PROBE_FAILED; + } + // No output in the naming mode: the installer words the "nothing is holding them" // case, and it owns the message either way. The acting modes say it themselves, // because their line is logged verbatim next to what they were asked to do. 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 index 8fac40040..8d6274e91 100644 --- a/tools/installer-holder-probe/payload_paths.cpp +++ b/tools/installer-holder-probe/payload_paths.cpp @@ -6,10 +6,6 @@ namespace { -/// The payload programs, by image name. A process running one of these is what the guard -/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. -constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; - /// 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 diff --git a/tools/installer-holder-probe/payload_paths.h b/tools/installer-holder-probe/payload_paths.h index 247fc64ed..ea63732f5 100644 --- a/tools/installer-holder-probe/payload_paths.h +++ b/tools/installer-holder-probe/payload_paths.h @@ -3,6 +3,18 @@ #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. diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index e3fb3e3c8..21ddd6988 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -27,9 +27,6 @@ constexpr int MAX_PLUGIN_DEPTH = 4; /// only keeps a snapshot with a cycle in it from walking forever. constexpr int MAX_ASKER_LEVELS = 8; -/// The payload files the installer replaces by name; the DLLs are matched by mask below. -constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; - void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -129,7 +126,12 @@ Holder holderOf(const RM_PROCESS_INFO &info) std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; - for (const wchar_t *name : PAYLOAD_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) { From d30d2752961fcbf3c3d1c7123f33bad5efe7fac4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:07 +0100 Subject: [PATCH 141/247] [Refactor] Move AnnotationDialog and action data types into game/ (#7411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in the closed #7410 (Move AnnotationDialog from game/ logic to game_graphics/) now that layers 1-2 are closed and this stack bottoms on master directly: - Add game_graphics/dialogs/dlg_annotation.{h,cpp} and move the QInputDialog subclass there; game/ no longer declares any widget type. - Add game/player/token_info.h and game/player/move_top_cards_until_options.h. - Move CardMenuActionType to game/player/card_menu_action_type.h. - Graphics dialog headers include the new game/ headers instead of defining the structs themselves. No behavior change. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../player/card_menu_action_type.h | 2 +- .../player/move_top_cards_until_options.h | 19 +++++++++++++++ cockatrice/src/game/player/player_actions.cpp | 14 ----------- cockatrice/src/game/player/player_actions.h | 6 ++--- cockatrice/src/game/player/player_logic.h | 12 ---------- cockatrice/src/game/player/token_info.h | 23 +++++++++++++++++++ .../game_graphics/dialogs/dlg_annotation.cpp | 13 +++++++++++ .../game_graphics/dialogs/dlg_annotation.h | 22 ++++++++++++++++++ .../game_graphics/dialogs/dlg_create_token.h | 13 ++--------- .../dialogs/dlg_move_top_cards_until.h | 9 ++------ .../game_graphics/player/menu/card_menu.cpp | 2 +- .../game_graphics/player/menu/move_menu.cpp | 2 +- .../game_graphics/player/player_dialogs.cpp | 1 + 14 files changed, 89 insertions(+), 50 deletions(-) rename cockatrice/src/{game_graphics => game}/player/card_menu_action_type.h (95%) create mode 100644 cockatrice/src/game/player/move_top_cards_until_options.h create mode 100644 cockatrice/src/game/player/token_info.h create mode 100644 cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp create mode 100644 cockatrice/src/game_graphics/dialogs/dlg_annotation.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4bd1bde56..8a40e3dc8 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -89,6 +89,7 @@ set(cockatrice_SOURCES 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 diff --git a/cockatrice/src/game_graphics/player/card_menu_action_type.h b/cockatrice/src/game/player/card_menu_action_type.h similarity index 95% rename from cockatrice/src/game_graphics/player/card_menu_action_type.h rename to cockatrice/src/game/player/card_menu_action_type.h index 4cae22716..e5c4cd84e 100644 --- a/cockatrice/src/game_graphics/player/card_menu_action_type.h +++ b/cockatrice/src/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/cockatrice/src/game/player/move_top_cards_until_options.h b/cockatrice/src/game/player/move_top_cards_until_options.h new file mode 100644 index 000000000..da8028b0f --- /dev/null +++ b/cockatrice/src/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/cockatrice/src/game/player/player_actions.cpp index 35682ede8..2be646572 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -3,8 +3,6 @@ #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" @@ -31,8 +29,6 @@ #include #include #include -#include -#include #include #include #include @@ -1495,16 +1491,6 @@ 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) { QString oldAnnotation; diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 8fff81635..e0825465c 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -9,11 +9,11 @@ #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 "card_menu_action_type.h" #include "libcockatrice/card/card_info.h" +#include "move_top_cards_until_options.h" +#include "token_info.h" #include #include diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index 59da7cc32..aa94dbb53 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -17,7 +17,6 @@ #include "libcockatrice/utility/playmat_params.h" #include -#include #include #include #include @@ -258,15 +257,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/token_info.h b/cockatrice/src/game/player/token_info.h new file mode 100644 index 000000000..99869f33e --- /dev/null +++ b/cockatrice/src/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_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..c63c5d7b2 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -7,6 +7,8 @@ #ifndef DLG_CREATETOKEN_H #define DLG_CREATETOKEN_H +#include "../../game/player/token_info.h" + #include #include #include @@ -25,17 +27,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..3b0fe19ec 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 @@ -7,6 +7,8 @@ #ifndef DLG_MOVE_TOP_CARDS_UNTIL_H #define DLG_MOVE_TOP_CARDS_UNTIL_H +#include "../../game/player/move_top_cards_until_options.h" + #include #include #include @@ -21,13 +23,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/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 137ec2ed1..eda71a2be 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -4,6 +4,7 @@ #include "../../../client/settings/card_counter_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../../game/abstract_game.h" +#include "../../../game/player/card_menu_action_type.h" #include "../../../game/player/player_info.h" #include "../../../game/player/player_manager.h" #include "../../../game/zones/card_zone_logic.h" @@ -12,7 +13,6 @@ #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" diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 2991100ec..80d748e19 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -2,10 +2,10 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../../game/player/card_menu_action_type.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 diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index 74f5de601..674b31ffa 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -7,6 +7,7 @@ #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" From 52c5bd2ea3a5669e3bf964b2a3a4a4092d379d33 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:07 +0100 Subject: [PATCH 142/247] [Refactor] Move table row mapping from TableZone into TableZoneLogic (#7413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move table row mapping from TableZone into TableZoneLogic * [Game] Use TableZoneLogic::ROW_COUNT directly per review * [Format] Match clang-format line breaking in table_zone --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 13 ++++----- .../src/game/zones/table_zone_logic.cpp | 21 +++++++++++++- cockatrice/src/game/zones/table_zone_logic.h | 15 ++++++++++ .../src/game_graphics/zones/table_zone.cpp | 28 ++++--------------- .../src/game_graphics/zones/table_zone.h | 13 +-------- 5 files changed, 47 insertions(+), 43 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 2be646572..85633b756 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -3,7 +3,6 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" #include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/zones/table_zone.h" #include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" #include "../board/counter_state.h" @@ -121,7 +120,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()); @@ -161,7 +160,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()); @@ -925,7 +924,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(); } @@ -979,7 +978,7 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo) .destroy = true, .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; - lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow); + lastTokenTableRow = TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow); emit requestEnableAndSetCreateAnotherTokenAction(lastTokenInfo.name); } @@ -1191,7 +1190,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; @@ -1977,7 +1976,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; diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index 07e4a759b..9343e34b0 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -40,4 +40,23 @@ CardItem *TableZoneLogic::takeCard(int position, int cardId, bool 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/cockatrice/src/game/zones/table_zone_logic.h index 8078bfd05..353a497e9 100644 --- a/cockatrice/src/game/zones/table_zone_logic.h +++ b/cockatrice/src/game/zones/table_zone_logic.h @@ -29,6 +29,21 @@ 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; diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index b704b79e0..f98d7e5cb 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -62,7 +62,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; @@ -397,7 +398,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 +424,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 +472,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..f3fc0dc5a 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -7,6 +7,7 @@ #ifndef TABLEZONE_H #define TABLEZONE_H +#include "../../game/zones/table_zone_logic.h" #include "../animated_item.h" #include "../card_dimensions.h" #include "select_zone.h" @@ -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. From 93c5d85996643db48580d75970628c0151bff7f1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:08 +0100 Subject: [PATCH 143/247] [Refactor] Move attach and arrow previews from game logic into CardMenu (#7414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 18 -------------- cockatrice/src/game/player/player_actions.h | 2 -- .../src/game/player/player_event_handler.h | 2 +- cockatrice/src/game/player/player_logic.h | 2 +- .../game_graphics/player/menu/card_menu.cpp | 24 +++++++++++++++---- .../src/game_graphics/player/menu/card_menu.h | 6 +++-- .../game_graphics/player/menu/player_menu.cpp | 2 +- .../game_graphics/player/menu/player_menu.h | 2 +- 8 files changed, 28 insertions(+), 30 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 85633b756..060f5ca6e 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1417,14 +1417,6 @@ 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) { actIncPT(selectedCards, 1, 0); @@ -1516,16 +1508,6 @@ 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) { QList commandList; diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index e0825465c..e0b1c461f 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -197,9 +197,7 @@ public slots: 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); diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 88254119b..44b43c3f1 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -267,7 +267,7 @@ signals: /** @} */ void cardZoneChanged(CardItem *card, bool sameZone); - void requestCardMenuUpdate(const CardItem *card); + void requestCardMenuUpdate(CardItem *card); private: /** Owning player instance. */ diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index aa94dbb53..e6a48af99 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -66,7 +66,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(CardItem *card); void counterAdded(CounterState *state); void counterRemoved(int counterId); void rearrangeCounters(); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index eda71a2be..769bdd53a 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -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(); @@ -108,15 +108,17 @@ 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 aAttach = new QAction(this); aDrawArrow = new QAction(this); + + // Actions that use activeCard, not selection — direct connection 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(aAttach, &QAction::triggered, this, &CardMenu::attachCard); + connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow); connect(aSelectAll, &QAction::triggered, actions, &PlayerActions::actSelectAll); connect(aSelectRow, &QAction::triggered, actions, &PlayerActions::actSelectRow); connect(aSelectColumn, &QAction::triggered, actions, &PlayerActions::actSelectColumn); @@ -501,6 +503,20 @@ void CardMenu::addRelatedCardActions() } } +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/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 44b184cfe..6a23890ee 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -87,7 +87,7 @@ void PlayerMenu::setMenusForGraphicItems() } } -QMenu *PlayerMenu::updateCardMenu(const CardItem *card) +QMenu *PlayerMenu::updateCardMenu(CardItem *card) { if (!card) { emit cardMenuUpdated(nullptr); diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index 97dd2fc56..27a5efe7e 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -43,7 +43,7 @@ signals: public slots: void setMenusForGraphicItems(); - QMenu *updateCardMenu(const CardItem *card); + QMenu *updateCardMenu(CardItem *card); private slots: void refreshShortcuts(); From 94b91be5a29f0a7b5e650ce76d74dacf60217eab Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:08 +0100 Subject: [PATCH 144/247] [Refactor] Pass the target card into PlayerActions instead of reading activeCard (#7415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 20 ++++++------------ cockatrice/src/game/player/player_actions.h | 10 ++++----- .../game_graphics/player/menu/card_menu.cpp | 21 +++++++++---------- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 060f5ca6e..d8c02396e 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -757,9 +757,8 @@ static void selectCardsInZone( } } -void PlayerActions::actSelectAll() +void PlayerActions::actSelectAll(const CardItem *card) { - const CardItem *card = player->getGame()->getActiveCard(); if (!card) { return; } @@ -767,9 +766,8 @@ void PlayerActions::actSelectAll() selectCardsInZone(card->getZone()); } -void PlayerActions::actSelectRow() +void PlayerActions::actSelectRow(const CardItem *card) { - const CardItem *card = player->getGame()->getActiveCard(); if (!card) { return; } @@ -780,9 +778,8 @@ void PlayerActions::actSelectRow() selectCardsInZone(card->getZone(), isSameRow); } -void PlayerActions::actSelectColumn() +void PlayerActions::actSelectColumn(const CardItem *card) { - const CardItem *card = player->getGame()->getActiveCard(); if (!card) { return; } @@ -996,25 +993,20 @@ void PlayerActions::actCreatePredefinedToken() actCreateAnotherToken(); } -void PlayerActions::actCreateRelatedCard() +void PlayerActions::actCreateRelatedCard(const CardItem *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 CardItem *sourceCard) { - const CardItem *sourceCard = player->getGame()->getActiveCard(); if (!sourceCard) { return; } diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index e0b1c461f..427fd8f66 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -164,9 +164,9 @@ 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 actSelectAll(const CardItem *card); + void actSelectRow(const CardItem *card); + void actSelectColumn(const CardItem *card); void actViewLibrary(); void actViewHand(); @@ -187,8 +187,8 @@ public slots: void actOpenDeckInDeckEditor(); void actCreatePredefinedToken(); - void actCreateRelatedCard(); - void actCreateAllRelatedCards(); + void actCreateRelatedCard(const CardItem *sourceCard, int index); + void actCreateAllRelatedCards(const CardItem *sourceCard); void actRequestMoveCardXCardsFromTopDialog(); void actMoveCardXCardsFromTop(QList selectedCards, int number); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 769bdd53a..d384f5521 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -108,20 +108,18 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts aHide = makeAction(this, [actions, sel]() { actions->actHide(sel()); }); aReduceLifeByPower = makeAction(this, [actions, sel]() { actions->actReduceLifeByPower(sel()); }); - // Actions bound to the card this menu was opened for + // Actions bound to the card this menu was opened for, not to the current selection aAttach = new QAction(this); aDrawArrow = new QAction(this); - - // Actions that use activeCard, not selection — direct connection aSelectAll = new QAction(this); aSelectRow = new QAction(this); aSelectColumn = new QAction(this); connect(aAttach, &QAction::triggered, this, &CardMenu::attachCard); connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow); - connect(aSelectAll, &QAction::triggered, actions, &PlayerActions::actSelectAll); - connect(aSelectRow, &QAction::triggered, actions, &PlayerActions::actSelectRow); - connect(aSelectColumn, &QAction::triggered, actions, &PlayerActions::actSelectColumn); + connect(aSelectAll, &QAction::triggered, this, [this, actions]() { actions->actSelectAll(card); }); + connect(aSelectRow, &QAction::triggered, this, [this, actions]() { actions->actSelectRow(card); }); + connect(aSelectColumn, &QAction::triggered, this, [this, actions]() { actions->actSelectColumn(card); }); aRevealToAll = new QAction(this); @@ -443,6 +441,7 @@ void CardMenu::addRelatedCardActions() if (relatedCards.isEmpty()) { return; } + auto *playerActions = player->getLogic()->getPlayerActions(); addSeparator(); int index = 0; @@ -486,9 +485,9 @@ 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, relatedIndex); }); addAction(createRelated); } @@ -497,8 +496,8 @@ 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); }); addAction(createRelatedCards); } } From 4972ebcb979445556997f3dbe8ab4a23660d9710 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:08 +0100 Subject: [PATCH 145/247] [Refactor] Move the active card from AbstractGame into GameScene (#7416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.cpp | 7 ------- cockatrice/src/game/abstract_game.h | 7 ------- cockatrice/src/game_graphics/board/card_item.cpp | 9 ++++++--- cockatrice/src/game_graphics/game_scene.cpp | 14 ++++++++++++-- cockatrice/src/game_graphics/game_scene.h | 7 +++++++ .../src/game_graphics/player/menu/player_menu.cpp | 2 +- 6 files changed, 26 insertions(+), 20 deletions(-) diff --git a/cockatrice/src/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp index efc575eff..7c342d1a9 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/cockatrice/src/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,11 +46,6 @@ 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 { PlayerLogic *player = playerManager->getPlayer(playerId); diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 326af8ac9..093916aa9 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -30,7 +30,6 @@ public: GameState *gameState; GameEventHandler *gameEventHandler; PlayerManager *playerManager; - CardItem *activeCard; GameMetaInfo *getGameMetaInfo() { @@ -59,12 +58,6 @@ 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; - } }; #endif // COCKATRICE_ABSTRACT_GAME_H diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 1901a634f..eae7cee18 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -67,9 +67,10 @@ CardItem::CardItem(PlayerLogic *_owner, 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; } @@ -567,7 +568,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/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index cc3f9f1d3..77a876030 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -124,6 +124,16 @@ QList GameScene::selectedCards() const return selectedCards; } +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); @@ -139,7 +149,7 @@ void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool sele } if (selectedItems().isEmpty()) { - owner->getGame()->setActiveCard(nullptr); + setActiveCard(nullptr); owner->requestCardMenuUpdate(nullptr); } } @@ -158,7 +168,7 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen return; } - card->getOwner()->getGame()->setActiveCard(card); + setActiveCard(card); if (auto *menu = view->getPlayerMenu()->updateCardMenu(card)) { menu->popup(screenPos); diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index c7cae1e76..03f76c7d7 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -69,6 +69,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 @@ -108,6 +109,12 @@ public: /** @brief Gets all selected CardItems. */ QList selectedCards() const; + /** @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. * @param player Player to add. diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 6a23890ee..7e69df66e 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -98,7 +98,7 @@ QMenu *PlayerMenu::updateCardMenu(CardItem *card) // 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) { + player->getGameScene()->getActiveCard() != card) { return nullptr; } From 563833a88ae310f7b5f1464627405d5872a916ea Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:09 +0100 Subject: [PATCH 146/247] [Refactor] Build Command_MoveCard in game/ instead of the zone views (#7417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/game/player/card_move_request.h | 22 ++++++++++++ cockatrice/src/game/player/player_actions.cpp | 36 +++++++++++++++++++ cockatrice/src/game/player/player_actions.h | 13 +++++++ .../src/game_graphics/zones/hand_zone.cpp | 17 +++------ .../src/game_graphics/zones/pile_zone.cpp | 21 +++-------- .../src/game_graphics/zones/stack_zone.cpp | 19 +++------- .../src/game_graphics/zones/table_zone.cpp | 28 +++++---------- .../src/game_graphics/zones/view_zone.cpp | 20 +++-------- 8 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 cockatrice/src/game/player/card_move_request.h diff --git a/cockatrice/src/game/player/card_move_request.h b/cockatrice/src/game/player/card_move_request.h new file mode 100644 index 000000000..bed46ab60 --- /dev/null +++ b/cockatrice/src/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/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index d8c02396e..0156146ca 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1999,6 +1999,42 @@ 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); +} + 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/cockatrice/src/game/player/player_actions.h index 427fd8f66..89e4dbbf7 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -11,6 +11,7 @@ #include "../../game_graphics/board/card_item.h" // IWYU pragma: keep - moc instantiates the metatype for QList, which Qt 6.4 needs complete #include "../board/card_list.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" @@ -36,6 +37,7 @@ class Message; } } // namespace google +class CardZoneLogic; class Command_MoveCard; class PendingCommand; class PlayerLogic; @@ -58,6 +60,17 @@ public: PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); PendingCommand *prepareGameCommand(const QList &cmdList); + /** + * @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); + void moveOneCardUntil(CardItem *card); void stopMoveTopCardsUntil(); diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 3e527c081..600b7efa9 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -18,7 +18,6 @@ #include #include #include -#include #include #include @@ -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 diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 77a87a4af..aa578097a 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -24,7 +24,6 @@ #include #include #include -#include #include #include #include @@ -80,23 +79,13 @@ void PileZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio 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::reorganizeCards() diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index de1ff8ba9..b94cc8e41 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -19,7 +19,6 @@ #include #include #include -#include #include #include @@ -79,25 +78,15 @@ void StackZone::handleDropEvent(const QList &dragItems, } } - 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) diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index f98d7e5cb..9d6a3830b 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -209,29 +208,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() diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index a2aa124dc..b6659ddda 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -27,7 +27,6 @@ #include #include #include -#include #include #include #include @@ -301,24 +300,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) From 095b135640e7b22762284d35e73c53862c0bb7ab Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:09 +0100 Subject: [PATCH 147/247] [Refactor] Move the remaining zone commands out of game_graphics/zones/ (#7418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move the remaining zone commands out of game_graphics/zones/ * [Game] Move zone dump request onto ZoneViewZoneLogic per review --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 35 +++++++++++++++++ cockatrice/src/game/player/player_actions.h | 15 ++++++++ cockatrice/src/game/zones/view_zone_logic.cpp | 14 ++++++- cockatrice/src/game/zones/view_zone_logic.h | 11 ++++++ .../src/game_graphics/zones/table_zone.cpp | 38 +++++-------------- .../src/game_graphics/zones/view_zone.cpp | 14 ++----- .../game_graphics/zones/view_zone_widget.cpp | 3 +- 7 files changed, 89 insertions(+), 41 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 0156146ca..bdef380e5 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -2035,6 +2036,40 @@ void PlayerActions::moveCards(CardZoneLogic *startZone, 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 (CardItem *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/cockatrice/src/game/player/player_actions.h index 89e4dbbf7..d8b2b3030 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -71,6 +71,21 @@ public: 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); + void moveOneCardUntil(CardItem *card); void stopMoveTopCardsUntil(); diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index eb3b7715f..fbda8eaae 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -3,6 +3,8 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" #include "../board/card_list.h" +#include "../player/player_actions.h" +#include "../player/player_logic.h" #include #include @@ -177,4 +179,14 @@ void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) emit removeFromViews(); } writeableRevealZone = _writeableRevealZone; -} \ No newline at end of file +} + +PendingCommand *ZoneViewZoneLogic::requestZoneDump() +{ + PlayerActions *actions = getPlayer()->getPlayerActions(); + PendingCommand *pend = actions->prepareZoneDump(this, numberCards); + if (pend != nullptr) { + actions->sendGameCommand(pend); + } + return pend; +} diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/cockatrice/src/game/zones/view_zone_logic.h index 6fae301e4..1abcd0516 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/cockatrice/src/game/zones/view_zone_logic.h @@ -12,6 +12,7 @@ class PlayerLogic; class QObject; +class PendingCommand; class ZoneViewZoneLogic : public CardZoneLogic { @@ -73,6 +74,16 @@ public: return origZone; } + /** + * @brief Prepares and sends the zone-dump command for this view. + * @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 command is sent + * asynchronously, so callers connect to finished() after calling this. + */ + PendingCommand *requestZoneDump(); + protected: void addCardImpl(CardItem *card, int x, int y) override; }; diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 9d6a3830b..748434033 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -267,35 +266,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); } } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand( - getLogic()->getPlayer()->getPlayerActions()->prepareGameCommand(cmdList)); + + const bool tapAll = std::any_of(selectedCards.begin(), selectedCards.end(), + [](const CardItem *card) { return !card->getTapped(); }); + + getLogic()->getPlayer()->getPlayerActions()->actToggleTapped(getLogic()->getName(), selectedCards, tapAll); } void TableZone::resizeToContents() diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index b6659ddda..716d2b963 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -26,7 +26,6 @@ #include #include #include -#include #include #include #include @@ -98,15 +97,10 @@ void ZoneViewZone::initializeCards(const QList &cardLis } 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); + PendingCommand *pend = qobject_cast(getLogic())->requestZoneDump(); + if (pend != nullptr) { + connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived); + } } else { const CardList &c = qobject_cast(getLogic())->getOriginalZone()->getCards(); int number = numberCards == -1 ? c.size() : (numberCards < c.size() ? numberCards : c.size()); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 8c82d6401..d6d42c3d9 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -45,7 +45,6 @@ #include #include #include -#include #include #include #include @@ -570,7 +569,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(); From 73d5a2a0aba721abe886e18ac44923ef45f1b161 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:10 +0100 Subject: [PATCH 148/247] [Refactor] Emit phase toolbar intent instead of protobuf commands (#7419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/game_event_handler.cpp | 22 +++++++++++ cockatrice/src/game/game_event_handler.h | 6 +++ .../src/game_graphics/phases_toolbar.cpp | 39 ++----------------- cockatrice/src/game_graphics/phases_toolbar.h | 21 +++++----- .../src/interface/widgets/tabs/tab_game.cpp | 10 ++++- 5 files changed, 49 insertions(+), 49 deletions(-) diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index 7b08af47c..bd2f4de58 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -29,11 +29,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -48,6 +51,7 @@ #include #include #include +#include #include #include @@ -223,6 +227,24 @@ 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); +} + void GameEventHandler::handleGameLeft() { sendGameCommand(Command_LeaveGame()); diff --git a/cockatrice/src/game/game_event_handler.h b/cockatrice/src/game/game_event_handler.h index 30021d295..8b2354b9e 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/cockatrice/src/game/game_event_handler.h @@ -127,6 +127,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(); 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/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 11813cdf4..c637dd0dd 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -1276,8 +1276,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); From 674a6079607ed791c283113dc62cbb42ab79b305 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:10 +0100 Subject: [PATCH 149/247] [Refactor] Build player counter commands in game/ instead of AbstractCounter (#7420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 19 +++++++++++++++++++ cockatrice/src/game/player/player_actions.h | 6 ++++++ .../game_graphics/board/abstract_counter.cpp | 17 +++-------------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index bdef380e5..19674f113 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -1450,6 +1451,24 @@ void PlayerActions::actFlowT(QList selectedCards) actIncPT(selectedCards, -1, 1); } +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 diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index d8b2b3030..c63b38d00 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -239,6 +239,12 @@ public slots: void actFlowP(QList selectedCards); void actFlowT(QList selectedCards); + /** @brief Adjusts a player counter by a signed delta. */ + void actIncrementCounter(int counterId, int delta); + + /** @brief Sets a player counter to an absolute value. */ + void actSetCounter(int counterId, int value); + void actReduceLifeByPower(QList selectedCards); void actRequestSetAnnotationDialog(QList selectedCards); diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index 8500b9c51..93eb9e011 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -23,8 +23,6 @@ #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) From 77f882ca37fd877a5e2203c343351d741a6d3621 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:10 +0100 Subject: [PATCH 150/247] [Refactor] Build deck setup commands in GameEventHandler instead of DeckViewContainer (#7421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/game_event_handler.cpp | 70 +++++++++++++++++++ cockatrice/src/game/game_event_handler.h | 66 +++++++++++++++++ .../deckview/deck_view_container.cpp | 44 ++---------- 3 files changed, 143 insertions(+), 37 deletions(-) diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index bd2f4de58..856475dfa 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -28,14 +28,19 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include +#include +#include +#include #include #include #include @@ -245,6 +250,71 @@ void GameEventHandler::handleDrawCard() 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/cockatrice/src/game/game_event_handler.h index 8b2354b9e..15e6b771c 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/cockatrice/src/game/game_event_handler.h @@ -29,8 +29,10 @@ // 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; @@ -151,6 +153,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_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 90b3bea11..eb858cf63 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -34,11 +34,6 @@ #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); } /** From 3ccd44155d2b02faf6523857cc79b93d19cab28c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:11 +0100 Subject: [PATCH 151/247] [Refactor] Build arrow and attach commands in game/ instead of ArrowItem (#7422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 57 ++++++++++++++++++ cockatrice/src/game/player/player_actions.h | 21 ++++++- .../src/game_graphics/board/arrow_item.cpp | 60 ++++--------------- .../src/game_graphics/board/arrow_item.h | 1 - 4 files changed, 88 insertions(+), 51 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 19674f113..d7547ef73 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -5,6 +5,7 @@ #include "../../game_graphics/dialogs/dlg_create_token.h" #include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" +#include "../board/arrow_data.h" #include "../board/counter_state.h" #include "../game_event_handler.h" #include "../zones/card_zone_logic.h" @@ -48,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +68,7 @@ #include #include #include +#include #include #include #include @@ -174,6 +177,60 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) sendGameCommand(cmd); } +void PlayerActions::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) { + 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(CardItem *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 = 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); + } + + sendGameCommand(cmd); +} + void PlayerActions::actViewLibrary() { emit requestZoneViewToggle(ZoneNames::DECK, -1); diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index c63b38d00..6f51650b5 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -41,7 +41,7 @@ class CardZoneLogic; class Command_MoveCard; class PendingCommand; class PlayerLogic; - +struct ArrowData; class PlayerActions : public QObject { Q_OBJECT @@ -86,6 +86,25 @@ public: */ 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(CardItem *startCard, const CardItem *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(CardItem *startCard, const ArrowData &arrow, int deleteInPhase); + void moveOneCardUntil(CardItem *card); void stopMoveTopCardsUntil(); diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index c5a9b9c5e..90a49819a 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -27,12 +27,7 @@ #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, 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, targetCard); } } @@ -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..ab11f6d84 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -131,7 +131,6 @@ private: PlayerLogic *player; QList childArrows; QMetaObject::Connection positionConnection; - void attachCards(CardItem *startCard, const CardItem *targetCard); public: explicit ArrowAttachItem(ArrowTarget *_startItem); From 85fd052fdb29e985ec3270b22bf14e247d97cec0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:11 +0100 Subject: [PATCH 152/247] [Refactor] Move ArrowRegistry out of game/ into game_graphics/board (#7423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 +- .../src/{game => game_graphics/board}/arrow_registry.cpp | 4 ++-- .../src/{game => game_graphics/board}/arrow_registry.h | 2 ++ cockatrice/src/game_graphics/game_scene.cpp | 2 +- cockatrice/src/game_graphics/game_scene.h | 6 +++++- 5 files changed, 11 insertions(+), 5 deletions(-) rename cockatrice/src/{game => game_graphics/board}/arrow_registry.cpp (93%) rename cockatrice/src/{game => game_graphics/board}/arrow_registry.h (95%) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 8a40e3dc8..80e4dd339 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -72,12 +72,12 @@ set(cockatrice_SOURCES 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 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..fe1c34263 100644 --- a/cockatrice/src/game/arrow_registry.cpp +++ b/cockatrice/src/game_graphics/board/arrow_registry.cpp @@ -1,7 +1,7 @@ #include "arrow_registry.h" -#include "../game_graphics/board/arrow_item.h" -#include "board/arrow_data.h" +#include "../../game/board/arrow_data.h" +#include "arrow_item.h" #include diff --git a/cockatrice/src/game/arrow_registry.h b/cockatrice/src/game_graphics/board/arrow_registry.h similarity index 95% rename from cockatrice/src/game/arrow_registry.h rename to cockatrice/src/game_graphics/board/arrow_registry.h index ca3b04090..f07c5c469 100644 --- a/cockatrice/src/game/arrow_registry.h +++ b/cockatrice/src/game_graphics/board/arrow_registry.h @@ -1,6 +1,8 @@ #ifndef COCKATRICE_ARROW_REGISTRY_H #define COCKATRICE_ARROW_REGISTRY_H +#include "../../game/board/arrow_data.h" + #include #include #include diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 77a876030..f70ab7e12 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -2,7 +2,6 @@ #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" @@ -13,6 +12,7 @@ #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" diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 03f76c7d7..e7eb7778b 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -1,7 +1,11 @@ #ifndef GAMESCENE_H #define GAMESCENE_H -#include "../game/arrow_registry.h" +#include "../game/board/arrow_data.h" +#include "../game/zones/card_zone_logic.h" +#include "animated_item.h" +#include "board/arrow_item.h" +#include "board/arrow_registry.h" #include #include From 6f0c5dca1238fcdd84cf02f383fea31dc83aeeaf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:11 +0100 Subject: [PATCH 153/247] [Refactor] Move LoadedDeck and DeckFileFormat data types into libcockatrice_deck_list (#7424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 -- cockatrice/src/filters/deck_filter_string.cpp | 2 +- cockatrice/src/filters/deck_filter_string.h | 1 + cockatrice/src/game/player/player_actions.cpp | 2 +- cockatrice/src/game/player/player_logic.h | 7 +++++++ .../src/game_graphics/deckview/deck_view_container.cpp | 4 ++-- cockatrice/src/interface/deck_loader/deck_loader.h | 7 ++++--- .../src/interface/intents/intent_open_local_deck.cpp | 4 ++-- cockatrice/src/interface/intents/intent_open_shared_deck.h | 2 +- .../interface/widgets/deck_editor/deck_state_manager.cpp | 2 +- .../src/interface/widgets/deck_editor/deck_state_manager.h | 3 ++- .../widgets/dialogs/dlg_convert_deck_to_cod_format.cpp | 2 +- .../widgets/dialogs/dlg_load_deck_from_clipboard.h | 2 ++ cockatrice/src/interface/widgets/general/home_widget.cpp | 4 ++-- .../interface/widgets/tabs/abstract_tab_deck_editor.cpp | 4 ++-- .../display/archidekt_api_response_deck_display_widget.cpp | 2 +- .../interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp | 2 +- cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp | 4 ++-- cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp | 2 +- cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp | 2 +- .../tabs/visual_deck_editor/tab_deck_editor_visual.cpp | 2 +- .../tabs/visual_deck_storage/tab_deck_storage_visual.cpp | 4 ++-- .../deck_preview/deck_preview_deck_tags_display_widget.cpp | 4 ++-- .../deck_preview/deck_preview_widget.cpp | 2 +- .../visual_deck_storage/visual_deck_storage_model.cpp | 4 ++-- .../visual_deck_storage/visual_deck_storage_model.h | 4 +++- .../visual_deck_storage_sort_filter_proxy_model.cpp | 2 +- libcockatrice_deck_list/CMakeLists.txt | 4 ++++ .../libcockatrice/deck_list}/deck_file_format.cpp | 0 .../libcockatrice/deck_list}/deck_file_format.h | 0 .../libcockatrice/deck_list}/loaded_deck.cpp | 0 .../libcockatrice/deck_list}/loaded_deck.h | 0 32 files changed, 51 insertions(+), 35 deletions(-) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/deck_file_format.cpp (100%) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/deck_file_format.h (100%) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/loaded_deck.cpp (100%) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/loaded_deck.h (100%) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 80e4dd339..2df79e0f6 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 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/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index d7547ef73..994221a98 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -3,7 +3,6 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" #include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/counter_state.h" @@ -47,6 +46,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index e6a48af99..d516fede0 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/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" @@ -22,6 +24,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index eb858cf63..870392aa6 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -6,9 +6,7 @@ #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,6 +31,8 @@ #include #include #include +#include +#include #include #include #include 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/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_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..06d60a756 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -5,7 +5,6 @@ #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 +62,7 @@ #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 964babcae..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 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 11c8df54a..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" @@ -16,6 +14,8 @@ #include #include #include +#include +#include #include #include #include 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/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index e61a5702c..224c10942 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -7,11 +7,13 @@ 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 ) @@ -25,10 +27,12 @@ add_library( 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 From c9853734ad9b3333e8e6b2cc1ede6471c7a62de7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:12 +0100 Subject: [PATCH 154/247] [Refactor] Move card identity and card data into CardState (#7425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 55 +++++++++++- cockatrice/src/game/board/card_state.h | 72 ++++++++++++++- .../board/abstract_card_item.cpp | 89 +++++++------------ .../game_graphics/board/abstract_card_item.h | 56 ++++++------ .../src/game_graphics/board/card_item.cpp | 15 ++-- .../src/game_graphics/board/card_item.h | 13 +-- 6 files changed, 200 insertions(+), 100 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 3f879a849..2bbc8429a 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -1,6 +1,9 @@ #include "card_state.h" #include +#include +#include +#include void CardState::resetState(bool keepAnnotations) { @@ -13,6 +16,56 @@ void CardState::resetState(bool keepAnnotations) attachedTo = nullptr; } +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(); +} + void CardState::setZone(CardZoneLogic *_zone) { if (zone == _zone) { @@ -110,4 +163,4 @@ void CardState::setAttachedTo(CardItem *_attachedTo) 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 index ca7aaf927..af85fea10 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -3,7 +3,10 @@ #include #include +#include #include +#include +#include #include class CardZoneLogic; @@ -14,6 +17,13 @@ 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; @@ -27,6 +37,12 @@ private: 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 attackingChanged(bool newValue); void countersChanged(const QMap &newCounters); void annotationChanged(const QString &newAnnotation); @@ -37,12 +53,66 @@ signals: void zoneChanged(CardState *changedCard, CardZoneLogic *newZone); public: - explicit CardState(QObject *parent, CardZoneLogic *_zone) : QObject(parent), zone(_zone) + 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); + int getId() const + { + return id; + } + + void setId(int _id) + { + id = _id; + } + + const CardRef &getCardRef() const + { + return cardRef; + } + + void setCardRef(const CardRef &_cardRef); + + 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; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index cc121cfb2..3bc0e1156 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,19 @@ 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(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, @@ -67,7 +72,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 +95,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 +162,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 +189,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 +240,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) { @@ -284,7 +274,7 @@ void AbstractCardItem::cacheBgColor() { QChar colorChar; if (color.isEmpty()) { - colorChar = exactCard.getInfo().getColorChar(); + colorChar = state->getCardInfo().getColorChar(); } else { colorChar = color.at(0); } @@ -314,17 +304,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 +323,6 @@ bool AbstractCardItem::animationEvent() return false; } -void AbstractCardItem::setFaceDown(bool _facedown) -{ - facedown = _facedown; - update(); -} - void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { cancelPendingSelectionCollapse(); @@ -357,7 +336,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 +350,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 +384,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..709265a2a 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -7,19 +7,11 @@ #ifndef ABSTRACTCARDITEM_H #define ABSTRACTCARDITEM_H +#include "../../game/board/card_state.h" #include "../animated_item.h" #include "arrow_target.h" #include "graphics_item_type.h" -#include -#include -#include -#include -#include -#include -#include -#include - class PlayerLogic; class CardInfo; class QPainter; @@ -29,11 +21,7 @@ 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; @@ -45,6 +33,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 +71,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 { @@ -123,20 +121,26 @@ public: 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/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index eae7cee18..6c91773fb 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -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) { @@ -128,7 +129,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 @@ -214,7 +215,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) state->getAttachedTo()->removeAttachedCard(this); } - gridPoint.setX(-1); + state->setGridPoint(QPoint(-1, state->getGridPoint().y())); 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 @@ -391,7 +392,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; @@ -538,16 +539,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; } diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 0737d7f9c..841061bef 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -33,9 +33,6 @@ class CardItem : public AbstractCardItem { Q_OBJECT private: - CardState *state; - - QPoint gridPoint; CardDragItem *dragItem; QList attachedCards; @@ -60,10 +57,6 @@ public: CardZoneLogic *_zone = nullptr); void retranslateUi(); - [[nodiscard]] CardState *getState() const - { - return state; - } [[nodiscard]] CardZoneLogic *getZone() const { return state->getZone(); @@ -72,15 +65,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 { From 4879ddbd0f55f5cdd140c7106586a8d9e4211a7d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:12 +0100 Subject: [PATCH 155/247] [Refactor] Move card selection actions to GameScene and p/t parsing to CardState (#7426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 31 ++++++++++ cockatrice/src/game/board/card_state.h | 21 +++++++ cockatrice/src/game/player/player_actions.cpp | 60 ++----------------- cockatrice/src/game/player/player_actions.h | 4 -- .../src/game_graphics/board/card_item.cpp | 31 ---------- .../src/game_graphics/board/card_item.h | 20 ------- cockatrice/src/game_graphics/game_scene.cpp | 51 ++++++++++++++++ cockatrice/src/game_graphics/game_scene.h | 9 +++ .../game_graphics/player/menu/card_menu.cpp | 6 +- .../src/game_graphics/tally/stats_tally.cpp | 4 +- 10 files changed, 121 insertions(+), 116 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 2bbc8429a..1f12253a4 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -66,6 +66,37 @@ void CardState::setFaceDown(bool _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) { diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index af85fea10..45f789aea 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -151,6 +152,26 @@ public: 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); + bool getDoesntUntap() const { return doesntUntap; diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 994221a98..1a91d28a5 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -795,58 +795,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) -{ - if (!card) { - return; - } - - selectCardsInZone(card->getZone()); -} - -void PlayerActions::actSelectRow(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 PlayerActions::actSelectColumn(const CardItem *card) -{ - 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()) { @@ -1356,7 +1304,7 @@ void PlayerActions::actIncPT(QList selectedCards, int deltaP, int de 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) : ""); @@ -1432,7 +1380,7 @@ 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; @@ -1440,7 +1388,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) { @@ -1537,7 +1485,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); diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 6f51650b5..daee72d2f 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -211,10 +211,6 @@ public slots: void actRequestMoveBottomCardsToDialog(const QString &targetZone, const QString &zoneDisplayName, bool faceDown); void moveBottomCardsTo(int number, const QString &targetZone, bool faceDown); - void actSelectAll(const CardItem *card); - void actSelectRow(const CardItem *card); - void actSelectColumn(const CardItem *card); - void actViewLibrary(); void actViewHand(); void actRequestViewTopCardsDialog(); diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 6c91773fb..cbce017d9 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -440,37 +440,6 @@ void CardItem::playCard(bool 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. diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 841061bef..0932f578b 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -143,26 +143,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/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index f70ab7e12..31188293d 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -124,6 +125,56 @@ 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 &cardItem : zone->getCards()) { + 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; diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index e7eb7778b..f0e94e14e 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -113,6 +113,15 @@ public: /** @brief Gets all selected CardItems. */ QList selectedCards() 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; diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index d384f5521..2174be3d8 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -117,9 +117,9 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts connect(aAttach, &QAction::triggered, this, &CardMenu::attachCard); connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow); - connect(aSelectAll, &QAction::triggered, this, [this, actions]() { actions->actSelectAll(card); }); - connect(aSelectRow, &QAction::triggered, this, [this, actions]() { actions->actSelectRow(card); }); - connect(aSelectColumn, &QAction::triggered, this, [this, actions]() { actions->actSelectColumn(card); }); + 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); 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); From 47c90cda10f7c72190a346ae69ca6781a453d461 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:12 +0100 Subject: [PATCH 156/247] [Refactor] Give graphics zones a card item cache and own card placement (#7427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 3 ++ cockatrice/src/game/board/card_state.h | 1 + .../src/game/zones/card_zone_algorithms.h | 10 +++--- cockatrice/src/game/zones/card_zone_logic.h | 2 ++ cockatrice/src/game/zones/pile_zone_logic.cpp | 4 +-- .../src/game/zones/table_zone_logic.cpp | 1 - cockatrice/src/game/zones/view_zone_logic.cpp | 1 + .../src/game_graphics/board/card_item.cpp | 15 ++++---- .../src/game_graphics/zones/card_zone.cpp | 23 ++++++++++-- .../src/game_graphics/zones/card_zone.h | 14 ++++++++ .../src/game_graphics/zones/hand_zone.cpp | 16 ++++----- .../src/game_graphics/zones/pile_zone.cpp | 27 ++++++++------ .../src/game_graphics/zones/pile_zone.h | 1 + .../src/game_graphics/zones/select_zone.cpp | 16 ++++----- .../src/game_graphics/zones/stack_zone.cpp | 4 +-- .../src/game_graphics/zones/table_zone.cpp | 36 +++++++++---------- .../src/game_graphics/zones/view_zone.cpp | 4 +-- .../card_zone_algorithms_test.cpp | 7 ---- 18 files changed, 111 insertions(+), 74 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 1f12253a4..cdd645eaf 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -14,6 +14,9 @@ void CardState::resetState(bool keepAnnotations) annotation.clear(); } attachedTo = nullptr; + setTapped(false, false); + setDoesntUntap(false); + emit stateReset(); } void CardState::setCardRef(const CardRef &_cardRef) diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index 45f789aea..278993ec2 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -43,6 +43,7 @@ signals: void cardPixmapUpdated(); void tappedChanged(bool newTapped, bool canAnimate); void facedownChanged(bool newFaceDown); + void stateReset(); void attackingChanged(bool newValue); void countersChanged(const QMap &newCounters); diff --git a/cockatrice/src/game/zones/card_zone_algorithms.h b/cockatrice/src/game/zones/card_zone_algorithms.h index 118a7d29d..35e96b86c 100644 --- a/cockatrice/src/game/zones/card_zone_algorithms.h +++ b/cockatrice/src/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.h b/cockatrice/src/game/zones/card_zone_logic.h index 2e232b258..acd290d71 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -53,6 +53,7 @@ public: void rawInsertCard(CardItem *card, int index) { cards.insert(index, card); + emit cardCountChanged(); } [[nodiscard]] const CardList &getCards() const @@ -63,6 +64,7 @@ public: void sortCards(const QList &options) { cards.sortBy(options); + emit cardCountChanged(); } [[nodiscard]] QString getName() const { diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/cockatrice/src/game/zones/pile_zone_logic.cpp index 40af25cc5..acb9f529b 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/cockatrice/src/game/zones/pile_zone_logic.cpp @@ -18,13 +18,12 @@ PileZoneLogic::PileZoneLogic(PlayerLogic *_player, void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) { - connect(card, &CardItem::sigPixmapUpdated, this, &PileZoneLogic::callUpdate); + connect(card->getState(), &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 +32,5 @@ void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) cards.at(x + 1)->setCardRef({}); } } - card->setVisible(false); card->resetState(); } \ No newline at end of file diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index 9343e34b0..83e5e55eb 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -29,7 +29,6 @@ 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) diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index fbda8eaae..f5a30ec71 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -168,6 +168,7 @@ void ZoneViewZoneLogic::clearCards() card->deleteLater(); } cards.clear(); + emit cardCountChanged(); } void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index cbce017d9..4f41a3fcc 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -63,6 +63,14 @@ CardItem::CardItem(PlayerLogic *_owner, update(); } }); + + connect(state, &CardState::stateReset, this, [this] { + attachedCards.clear(); + if (scene()) { + static_cast(scene())->unregisterAnimationItem(this); + } + update(); + }); } void CardItem::prepareDelete() @@ -250,13 +258,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) diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index d09bb588c..8ab942a67 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -18,12 +18,31 @@ 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::setGraphicsVisibility, this, [this](bool v) { this->setVisible(v); }); connect(logic, &CardZoneLogic::updateGraphics, this, [this]() { update(); }); connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::reorganizeCards); } +void CardZone::invalidateItems() +{ + itemsDirty = true; +} + +const QList &CardZone::cardItems() const +{ + if (itemsDirty) { + items = getLogic()->getCards(); + itemsDirty = false; + } + return items; +} + void CardZone::onCardAdded(CardItem *addedCard) { addedCard->setParentItem(this); @@ -33,8 +52,8 @@ void CardZone::onCardAdded(CardItem *addedCard) 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..950b54446 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -76,6 +76,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 +92,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 600b7efa9..8cd5e4631 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -47,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; } } @@ -83,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) { @@ -124,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/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index aa578097a..7696ceafd 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -66,15 +66,14 @@ 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 &) @@ -88,6 +87,15 @@ void PileZone::handleDropEvent(const QList &dragItems, CardZoneL getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); } +void PileZone::onCardAdded(CardItem *addedCard) +{ + CardZone::onCardAdded(addedCard); + if (addedCard) { + addedCard->setPos(0, 0); + addedCard->setVisible(false); + } +} + void PileZone::reorganizeCards() { update(); @@ -115,15 +123,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); @@ -136,8 +143,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..5dc02a0e4 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(CardItem *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..9b0c4e60a 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -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); } @@ -247,7 +247,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/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index b94cc8e41..4c97a1969 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -72,7 +72,7 @@ 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; } @@ -102,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 748434033..c1e3b9f76 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -228,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; } @@ -238,17 +238,17 @@ 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; @@ -285,9 +285,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(); } } @@ -308,9 +308,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; @@ -327,8 +327,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; } @@ -339,8 +339,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; } @@ -348,8 +348,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); } diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 716d2b963..78fc662b6 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -139,8 +139,8 @@ 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); 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) From 2335e064ac4b3cbd194c0b43b7ccd0804464b95b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:13 +0100 Subject: [PATCH 157/247] [Refactor] Move the attachment graph and card view teardown onto CardState (#7428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 8 ++++- cockatrice/src/game/board/card_state.h | 36 ++++++++++++++++--- .../src/game/player/player_event_handler.cpp | 6 ++-- cockatrice/src/game/player/player_logic.cpp | 4 +-- cockatrice/src/game/zones/view_zone_logic.cpp | 4 +-- .../src/game_graphics/board/card_item.cpp | 6 ++-- .../src/game_graphics/board/card_item.h | 9 +++-- 7 files changed, 54 insertions(+), 19 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index cdd645eaf..95776c237 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -14,11 +14,17 @@ void CardState::resetState(bool keepAnnotations) annotation.clear(); } attachedTo = nullptr; + attachedCards.clear(); setTapped(false, false); setDoesntUntap(false); emit stateReset(); } +void CardState::deleteView() +{ + emit viewDeleteRequested(); +} + void CardState::setCardRef(const CardRef &_cardRef) { if (cardRef == _cardRef) { @@ -189,7 +195,7 @@ void CardState::setDestroyOnZoneChange(bool _destroyOnZoneChange) emit stateChanged(); } -void CardState::setAttachedTo(CardItem *_attachedTo) +void CardState::setAttachedTo(CardState *_attachedTo) { if (attachedTo == _attachedTo) { return; diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index 278993ec2..2f9d5917d 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_CARD_STATE_H #define COCKATRICE_CARD_STATE_H +#include #include #include #include @@ -32,7 +33,8 @@ private: bool doesntUntap = false; bool destroyOnZoneChange = false; - CardItem *attachedTo = nullptr; + CardState *attachedTo = nullptr; + QList attachedCards; CardZoneLogic *zone = nullptr; signals: @@ -51,9 +53,11 @@ signals: void ptChanged(const QString &newPt); void doesntUntapChanged(bool newValue); void destroyOnZoneChangeChanged(bool newValue); - void attachedToChanged(CardItem *newAttachedTo); + 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) @@ -62,6 +66,15 @@ public: 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; @@ -187,12 +200,27 @@ public: void setDestroyOnZoneChange(bool _destroyOnZoneChange); - CardItem *getAttachedTo() const + CardState *getAttachedTo() const { return attachedTo; } - void setAttachedTo(CardItem *_attachedTo); + void setAttachedTo(CardState *_attachedTo); + + void addAttachedCard(CardItem *card) + { + attachedCards.append(card); + } + + void removeAttachedCard(CardItem *card) + { + attachedCards.removeOne(card); + } + + const QList &getAttachedCards() const + { + return attachedCards; + } }; #endif // COCKATRICE_CARD_STATE_H diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index 18f6573df..55e5d9d8f 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -356,7 +356,7 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv } if (card->getAttachedTo() && (startZone != targetZone)) { - CardItem *parentCard = card->getAttachedTo(); + CardState *parentCard = card->getAttachedTo(); card->setAttachedTo(nullptr); parentCard->getZone()->reorganizeCards(); } @@ -440,7 +440,7 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) emit logDestroyCard(player, card->getName()); zone->takeCard(-1, event.card_id(), true); - card->deleteLater(); + card->getState()->deleteView(); } void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) @@ -469,7 +469,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) return; } - CardItem *oldParent = startCard->getAttachedTo(); + CardState *oldParent = startCard->getAttachedTo(); startCard->setAttachedTo(targetCard); diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index b545b7088..552e9f44c 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -251,7 +251,7 @@ void PlayerLogic::deleteCard(CardItem *card) } else if (dialogSemaphore) { cardsToDelete.append(card); } else { - card->deleteLater(); + card->getState()->deleteView(); } } @@ -332,7 +332,7 @@ bool PlayerLogic::clearCardsToDelete() for (auto &i : cardsToDelete) { if (i != nullptr) { - i->deleteLater(); + i->getState()->deleteView(); } } cardsToDelete.clear(); diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index f5a30ec71..b7d953549 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -147,7 +147,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) } CardItem *card = cards.takeAt(position); - card->deleteLater(); + card->getState()->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. @@ -165,7 +165,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { - card->deleteLater(); + card->getState()->deleteView(); } cards.clear(); emit cardCountChanged(); diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 4f41a3fcc..0b2b3cf82 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -65,12 +65,13 @@ CardItem::CardItem(PlayerLogic *_owner, }); connect(state, &CardState::stateReset, this, [this] { - attachedCards.clear(); if (scene()) { static_cast(scene())->unregisterAnimationItem(this); } update(); }); + + connect(state, &CardState::viewDeleteRequested, this, &CardItem::deleteLater); } void CardItem::prepareDelete() @@ -84,6 +85,7 @@ void CardItem::prepareDelete() owner = nullptr; } + const QList &attachedCards = state->getAttachedCards(); while (!attachedCards.isEmpty()) { attachedCards.first()->setZone(nullptr); // so that it won't try to call reorganizeCards() attachedCards.first()->setAttachedTo(nullptr); @@ -224,7 +226,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) } state->setGridPoint(QPoint(-1, state->getGridPoint().y())); - state->setAttachedTo(_attachedTo); + state->setAttachedTo(_attachedTo == nullptr ? nullptr : _attachedTo->getState()); 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 diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 0932f578b..79c3dcdbd 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -34,7 +34,6 @@ class CardItem : public AbstractCardItem Q_OBJECT private: CardDragItem *dragItem; - QList attachedCards; void prepareDelete(); void handleClickedToPlay(bool shiftHeld); @@ -116,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) { - attachedCards.append(card); + state->addAttachedCard(card); } void removeAttachedCard(CardItem *card) { - attachedCards.removeOne(card); + state->removeAttachedCard(card); } [[nodiscard]] const QList &getAttachedCards() const { - return attachedCards; + return state->getAttachedCards(); } void resetState(bool keepAnnotations = false); void processCardInfo(const ServerInfo_Card &_info); From edb5caec8a77a9f2dfe111264abf1aa60aa5bf8e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:13 +0100 Subject: [PATCH 158/247] [Refactor] Hold zone views and card view teardown outside game graphics (#7429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/game/player/player_event_handler.cpp | 25 ++++++------------- .../src/game/player/player_event_handler.h | 9 +++++++ cockatrice/src/game/zones/card_zone_logic.cpp | 19 ++++++-------- cockatrice/src/game/zones/card_zone_logic.h | 10 +++++--- cockatrice/src/game/zones/view_zone_logic.cpp | 5 ++++ cockatrice/src/game/zones/view_zone_logic.h | 7 ++++++ cockatrice/src/game_graphics/game_scene.cpp | 21 ++++++++++++++++ cockatrice/src/game_graphics/game_scene.h | 9 +++++++ .../src/game_graphics/zones/view_zone.cpp | 18 +++++++------ 9 files changed, 84 insertions(+), 39 deletions(-) diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index 55e5d9d8f..ff81498ac 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -1,7 +1,6 @@ #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" @@ -9,6 +8,7 @@ #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" @@ -76,12 +76,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!"; @@ -345,9 +345,9 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv 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->getState(), zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); if (event.has_card_name()) { QString name = QString::fromStdString(event.card_name()); QString providerId = @@ -361,22 +361,13 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv 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(); 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 diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 44b43c3f1..37215d61d 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -28,6 +28,7 @@ #include class CardItem; +class CardState; class CardZoneLogic; class PlayerLogic; class Event_AttachCard; @@ -268,6 +269,14 @@ signals: void cardZoneChanged(CardItem *card, bool sameZone); void requestCardMenuUpdate(CardItem *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. */ diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/cockatrice/src/game/zones/card_zone_logic.cpp index d997ab9ad..c4431f20a 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/cockatrice/src/game/zones/card_zone_logic.cpp @@ -2,9 +2,6 @@ #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 "../player/player_actions.h" #include "../player/player_info.h" #include "../player/player_logic.h" @@ -47,16 +44,16 @@ CardZoneLogic::CardZoneLogic(PlayerLogic *_player, void CardZoneLogic::addCard(CardItem *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)) { + for (auto *viewLogic : views) { + if (viewLogic->prepareAddCard(x)) { auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); copy->setFaceDown(card->getFaceDown()); - view->getLogic()->addCard(copy, reorganize, x, y); + viewLogic->addCard(copy, reorganize, x, y); } } @@ -90,8 +87,8 @@ 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); @@ -107,7 +104,7 @@ CardItem *CardZoneLogic::getCard(int cardId) { CardItem *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, @@ -122,7 +119,7 @@ CardItem *CardZoneLogic::getCard(int cardId) void CardZoneLogic::removeCard(CardItem *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; } diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/cockatrice/src/game/zones/card_zone_logic.h index acd290d71..e01eec74c 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -21,7 +21,11 @@ class CardItem; inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; -class ZoneViewZone; +class ZoneViewZoneLogic; +class QMenu; +class QAction; +class QPainter; +class CardDragItem; class CardZoneLogic : public QObject { @@ -79,7 +83,7 @@ public: { return cards.getContentsKnown(); } - QList &getViews() + QList &getViews() { return views; } @@ -111,7 +115,7 @@ protected: PlayerLogic *player; QString name; CardList cards; - QList views; + QList views; bool hasCardAttr; bool isShufflable; bool alwaysRevealTopCard; diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index b7d953549..f5e597abe 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -162,6 +162,11 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) reorganizeCards(); } +void ZoneViewZoneLogic::requestClose() +{ + emit closeView(); +} + void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/cockatrice/src/game/zones/view_zone_logic.h index 1abcd0516..71261e204 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/cockatrice/src/game/zones/view_zone_logic.h @@ -48,6 +48,13 @@ public: 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(); diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 31188293d..f54e3ae28 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -291,6 +291,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(); } @@ -650,6 +652,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 f0e94e14e..ca6441e91 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -43,6 +43,7 @@ class ZoneViewWidget; class CardZone; class AbstractCardItem; class CardItem; +class CardState; class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; @@ -282,6 +283,14 @@ public slots: void requestArrowDeletion(int playerId, int arrowId); void onCardZoneChanged(CardItem *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/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 78fc662b6..7d47b3259 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -40,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); } /** @@ -64,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(); } From 5d0d466930a3bb3eb99b9fc8d8014031e5d7a173 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:13 +0100 Subject: [PATCH 159/247] [Refactor] Add card name, printing id, and color to CardState (#7433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 6 ++++++ cockatrice/src/game/board/card_state.h | 19 +++++++++++++++++++ .../board/abstract_card_item.cpp | 13 ++++++++----- .../game_graphics/board/abstract_card_item.h | 5 ++--- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 95776c237..df7963e00 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -20,6 +20,12 @@ void CardState::resetState(bool keepAnnotations) emit stateReset(); } +void CardState::setColor(const QString &_color) +{ + color = _color; + emit colorChanged(); +} + void CardState::deleteView() { emit viewDeleteRequested(); diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index 2f9d5917d..bce5a298d 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -32,6 +32,7 @@ private: QString pt; bool doesntUntap = false; bool destroyOnZoneChange = false; + QString color; CardState *attachedTo = nullptr; QList attachedCards; @@ -46,6 +47,7 @@ signals: void tappedChanged(bool newTapped, bool canAnimate); void facedownChanged(bool newFaceDown); void stateReset(); + void colorChanged(); void attackingChanged(bool newValue); void countersChanged(const QMap &newCounters); @@ -92,6 +94,16 @@ public: void setCardRef(const CardRef &_cardRef); + QString getName() const + { + return cardRef.name; + } + + QString getProviderId() const + { + return cardRef.providerId; + } + ExactCard getCard() const { return exactCard; @@ -186,6 +198,13 @@ public: */ static QVariantList parsePT(const QString &pt); + [[nodiscard]] QString getColor() const + { + return color; + } + + void setColor(const QString &_color); + bool getDoesntUntap() const { return doesntUntap; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 3bc0e1156..0e8b6ced3 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -54,6 +54,10 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef 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(); }); @@ -265,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()) { + 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()) { diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index 709265a2a..01fc4bb0a 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -23,7 +23,6 @@ class AbstractCardItem : public ArrowTarget, public IAnimatedItem protected: CardState *state; int tapAngle; - QString color; QColor bgColor; private: @@ -114,9 +113,9 @@ public: { return isHovered; } - QString getColor() const + [[nodiscard]] QString getColor() const { - return color; + return state->getColor(); } void setColor(const QString &_color); bool getTapped() const From 65e9f1b370ea327904ecc15c10e768888931c34d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:14 +0100 Subject: [PATCH 160/247] [Refactor] Retype card containers and zone logic onto CardState (#7434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Retype card containers and zone logic onto CardState * [Game] Restore attach bookkeeping via CardItem and repaint on state change * [Refactor] Give custom zones a hidden graphics owner Custom PileZoneLogic zones had no CardZone, so moving a card into one left the CardItem drawn at its old spot. Create a hidden PileZone in onCustomZoneAdded so cards are reparented and hidden. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.cpp | 2 +- cockatrice/src/game/abstract_game.h | 3 +- cockatrice/src/game/board/card_list.cpp | 49 +++++++-------- cockatrice/src/game/board/card_list.h | 8 +-- cockatrice/src/game/player/player_actions.cpp | 6 +- .../src/game/player/player_event_handler.cpp | 62 ++++++++++++------- .../src/game/player/player_event_handler.h | 12 ++-- cockatrice/src/game/player/player_logic.cpp | 29 ++++++--- cockatrice/src/game/player/player_logic.h | 4 +- cockatrice/src/game/zones/card_zone_logic.cpp | 24 +++---- cockatrice/src/game/zones/card_zone_logic.h | 14 ++--- cockatrice/src/game/zones/hand_zone_logic.cpp | 4 +- cockatrice/src/game/zones/hand_zone_logic.h | 2 +- cockatrice/src/game/zones/pile_zone_logic.cpp | 8 +-- cockatrice/src/game/zones/pile_zone_logic.h | 2 +- .../src/game/zones/stack_zone_logic.cpp | 4 +- cockatrice/src/game/zones/stack_zone_logic.h | 2 +- .../src/game/zones/table_zone_logic.cpp | 8 +-- cockatrice/src/game/zones/table_zone_logic.h | 6 +- cockatrice/src/game/zones/view_zone_logic.cpp | 10 +-- cockatrice/src/game/zones/view_zone_logic.h | 2 +- .../src/game_graphics/board/card_item.cpp | 10 ++- cockatrice/src/game_graphics/game_scene.cpp | 12 +++- .../game_graphics/log/message_log_widget.cpp | 10 +-- .../game_graphics/log/message_log_widget.h | 11 ++-- .../player/player_graphics_item.cpp | 16 +++++ .../src/game_graphics/zones/card_zone.cpp | 19 ++++-- .../src/game_graphics/zones/card_zone.h | 5 +- .../src/game_graphics/zones/pile_zone.cpp | 9 +-- .../src/game_graphics/zones/pile_zone.h | 2 +- .../src/game_graphics/zones/select_zone.cpp | 11 ++-- .../src/game_graphics/zones/select_zone.h | 2 +- .../src/game_graphics/zones/view_zone.cpp | 23 +++---- 33 files changed, 232 insertions(+), 159 deletions(-) diff --git a/cockatrice/src/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp index 7c342d1a9..a93f44018 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/cockatrice/src/game/abstract_game.cpp @@ -46,7 +46,7 @@ void AbstractGame::loadReplay(const GameReplay *replay) gameMetaInfo->setSpectatorsOmniscient(true); } -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/cockatrice/src/game/abstract_game.h index 093916aa9..505bfc1b5 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -13,6 +13,7 @@ class CardItem; class AbstractClient; +class CardState; class GameEventHandler; class GameMetaInfo; class GameReplay; @@ -57,7 +58,7 @@ public: void loadReplay(const GameReplay *replay); - CardItem *getCard(int playerId, const QString &zoneName, int cardId) const; + CardState *getCard(int playerId, const QString &zoneName, int cardId) const; }; #endif // COCKATRICE_ABSTRACT_GAME_H diff --git a/cockatrice/src/game/board/card_list.cpp b/cockatrice/src/game/board/card_list.cpp index 94bae67cd..361c51502 100644 --- a/cockatrice/src/game/board/card_list.cpp +++ b/cockatrice/src/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/cockatrice/src/game/board/card_list.h index b5427df0d..b3037d0f6 100644 --- a/cockatrice/src/game/board/card_list.h +++ b/cockatrice/src/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/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 1a91d28a5..c0f4ef11d 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1614,7 +1614,9 @@ 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(qobject_cast(state->parent())); + } } QList commandList; @@ -1687,7 +1689,7 @@ void PlayerActions::actHide(QList selectedCards) for (const auto &item : selectedCards) { auto *card = static_cast(item); if (card && isUnwritableRevealZone(card->getZone())) { - card->getZone()->removeCard(card); + card->getZone()->removeCard(card->getState()); } } } diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index ff81498ac..4698aeced 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -53,6 +53,11 @@ #include #include +static CardItem *toCardItem(CardState *card) +{ + return card == nullptr ? nullptr : qobject_cast(card->parent()); +} + PlayerEventHandler::PlayerEventHandler(PlayerLogic *_player) : QObject(_player), player(_player) { connect(this, &PlayerEventHandler::requestCardMenuUpdate, player, &PlayerLogic::requestCardMenuUpdate); @@ -171,7 +176,7 @@ void PlayerEventHandler::eventCreateToken(const Event_CreateToken &event) card->setFaceDown(event.face_down()); emit logCreateToken(player, card->getName(), card->getPT(), card->getFaceDown()); - zone->addCard(card, true, event.x(), event.y()); + zone->addCard(card->getState(), true, event.x(), event.y()); } void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, @@ -193,7 +198,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 +208,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,14 +269,14 @@ 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; } int oldValue = card->getCounters().value(event.counter_id(), 0); card->setCounter(event.counter_id(), event.counter_value()); - emit requestCardMenuUpdate(card); + emit requestCardMenuUpdate(toCardItem(card)); emit logSetCardCounter(player, card->getName(), event.counter_id(), event.counter_value(), oldValue); } @@ -341,13 +346,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; } const bool zoneChanged = startZone != targetZone; const bool ownerChanged = zoneChanged && startZone->getPlayer() != targetZone->getPlayer(); - emit cardViewRefreshRequested(card->getState(), zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); + emit cardViewRefreshRequested(card, zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); if (event.has_card_name()) { QString name = QString::fromStdString(event.card_name()); QString providerId = @@ -357,7 +362,13 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (card->getAttachedTo() && (startZone != targetZone)) { CardState *parentCard = card->getAttachedTo(); - card->setAttachedTo(nullptr); + // Route through CardItem so the old parent's attachedCards list is cleaned up: CardState::setAttachedTo + // only repoints the pointer, leaving a dangling entry that reorganizeCards/prepareDelete would dereference. + if (CardItem *item = toCardItem(card)) { + item->setAttachedTo(nullptr); + } else { + card->setAttachedTo(nullptr); + } parentCard->getZone()->reorganizeCards(); } @@ -366,7 +377,7 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (startZone != targetZone) { const QList &attachedCards = card->getAttachedCards(); for (auto attachedCard : attachedCards) { - emit targetZone->cardAdded(attachedCard); + emit targetZone->cardAdded(attachedCard->getState()); } } @@ -380,12 +391,12 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv targetZone->addCard(card, true, x, y); - emit cardZoneChanged(card, startZone == targetZone); - emit requestCardMenuUpdate(card); + emit cardZoneChanged(toCardItem(card), startZone == targetZone); + emit requestCardMenuUpdate(toCardItem(card)); if (player->getPlayerActions()->isMovingCardsUntil() && startZoneString == ZoneNames::DECK && targetZone->getName() == ZoneNames::STACK) { - player->getPlayerActions()->moveOneCardUntil(card); + player->getPlayerActions()->moveOneCardUntil(toCardItem(card)); } } @@ -395,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; } @@ -408,7 +419,7 @@ void PlayerEventHandler::eventFlipCard(const Event_FlipCard &event) emit logFlipCard(player, card->getName(), event.face_down()); card->setFaceDown(event.face_down()); - emit requestCardMenuUpdate(card); + emit requestCardMenuUpdate(toCardItem(card)); } void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) @@ -418,7 +429,7 @@ 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; } @@ -431,7 +442,7 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) emit logDestroyCard(player, card->getName()); zone->takeCard(-1, event.card_id(), true); - card->getState()->deleteView(); + card->deleteView(); } void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) @@ -439,7 +450,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) { @@ -455,14 +466,21 @@ 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; } CardState *oldParent = startCard->getAttachedTo(); - startCard->setAttachedTo(targetCard); + // Route through CardItem::setAttachedTo to keep the attachedCards graph, grid-x reset and zone emits intact: + // CardState::setAttachedTo alone only repoints the pointer, so the target would never list the card and the old + // parent would keep a dangling pointer. + if (CardItem *startItem = toCardItem(startCard)) { + startItem->setAttachedTo(toCardItem(targetCard)); + } else { + startCard->setAttachedTo(targetCard); + } startZone->reorganizeCards(); if ((startZone != targetZone) && targetZone) { @@ -477,7 +495,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) } else { emit logUnattachCard(player, startCard->getName()); } - emit requestCardMenuUpdate(startCard); + emit requestCardMenuUpdate(toCardItem(startCard)); } void PlayerEventHandler::eventDrawCards(const Event_DrawCards &event) @@ -489,7 +507,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}); @@ -537,7 +555,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/cockatrice/src/game/player/player_event_handler.h index 37215d61d..6b721bf61 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -239,7 +239,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, @@ -249,11 +249,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, @@ -283,7 +283,7 @@ private: 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_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 552e9f44c..f6a3a50e7 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -33,6 +33,11 @@ #include #include +static CardItem *toCardItem(CardState *card) +{ + return card == nullptr ? nullptr : qobject_cast(card->parent()); +} + PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), @@ -172,7 +177,7 @@ 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); + zone->addCard((new CardItem(this))->getState(), false, -1); } } else { for (int j = 0; j < cardListSize; ++j) { @@ -184,9 +189,9 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) // 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()); + zone->addCard(card->getState(), false, cardInfo.x(), cardInfo.y()); } else { - zone->addCard(card, false, -1); + zone->addCard(card->getState(), false, -1); } } } @@ -219,15 +224,21 @@ 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) { continue; } - startCard->setAttachedTo(targetCard); + // Route through CardItem::setAttachedTo to maintain the attachedCards graph and reparenting; + // CardState::setAttachedTo alone only repoints the pointer. + if (CardItem *startItem = toCardItem(startCard)) { + startItem->setAttachedTo(toCardItem(targetCard)); + } else { + startCard->setAttachedTo(targetCard); + } } } } @@ -244,14 +255,14 @@ void PlayerLogic::addCard(CardItem *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->getState()->deleteView(); + card->deleteView(); } } @@ -332,7 +343,7 @@ bool PlayerLogic::clearCardsToDelete() for (auto &i : cardsToDelete) { if (i != nullptr) { - i->getState()->deleteView(); + i->deleteView(); } } cardsToDelete.clear(); diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index d516fede0..ceaeff10b 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -104,7 +104,7 @@ public: void processCardAttachment(const ServerInfo_Player &info); void addCard(CardItem *c); - void deleteCard(CardItem *c); + void deleteCard(CardState *c); bool clearCardsToDelete(); @@ -256,7 +256,7 @@ private: QMap counters; bool dialogSemaphore; - QList cardsToDelete; + QList cardsToDelete; // Playmat from player properties (for opponent display) CardRef remotePlaymatCard; diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/cockatrice/src/game/zones/card_zone_logic.cpp index c4431f20a..8cce9f957 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/cockatrice/src/game/zones/card_zone_logic.cpp @@ -41,7 +41,7 @@ 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(CardZoneLogicLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; @@ -53,7 +53,7 @@ void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); copy->setFaceDown(card->getFaceDown()); - viewLogic->addCard(copy, reorganize, x, y); + viewLogic->addCard(copy->getState(), reorganize, x, y); } } @@ -68,7 +68,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 @@ -91,7 +91,7 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) viewLogic->removeCard(position, toNewZone); } - CardItem *c = cards.takeAt(position); + CardState *c = cards.takeAt(position); c->setId(cardId); @@ -100,9 +100,9 @@ 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(CardZoneLogicLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; return nullptr; @@ -116,7 +116,7 @@ CardItem *CardZoneLogic::getCard(int cardId) return c; } -void CardZoneLogic::removeCard(CardItem *card) +void CardZoneLogic::removeCard(CardState *card) { if (!card) { qCWarning(CardZoneLogicLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; @@ -132,8 +132,8 @@ void CardZoneLogic::removeCard(CardItem *card) void CardZoneLogic::refreshCardInfos() { - for (const auto &cardItem : cards) { - cardItem->refreshCardInfo(); + for (const auto &cardState : cards) { + cardState->refreshCardInfo(); } } @@ -165,17 +165,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) { - emit attachedCard->getZone()->cardAdded(attachedCard); + emit attachedCard->getZone()->cardAdded(attachedCard->getState()); } } // 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/cockatrice/src/game/zones/card_zone_logic.h index e01eec74c..2a6b50c5c 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -32,7 +32,7 @@ class CardZoneLogic : public QObject Q_OBJECT signals: - void cardAdded(CardItem *addedCard); + void cardAdded(CardState *addedCard); void cardCountChanged(); void reorganizeCards(); void updateGraphics(); @@ -47,14 +47,14 @@ 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(); @@ -120,7 +120,7 @@ protected: 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/cockatrice/src/game/zones/hand_zone_logic.cpp index cea17d031..1d3f54929 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.cpp +++ b/cockatrice/src/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/cockatrice/src/game/zones/hand_zone_logic.h index c7d63bc0d..f8756eafd 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.h +++ b/cockatrice/src/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/cockatrice/src/game/zones/pile_zone_logic.cpp index acb9f529b..42ef4f0b2 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/cockatrice/src/game/zones/pile_zone_logic.cpp @@ -1,7 +1,7 @@ #include "pile_zone_logic.h" -#include "../../game_graphics/board/card_item.h" #include "../board/card_list.h" +#include "../board/card_state.h" class PlayerLogic; class QObject; @@ -16,9 +16,9 @@ PileZoneLogic::PileZoneLogic(PlayerLogic *_player, { } -void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void PileZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { - connect(card->getState(), &CardState::cardPixmapUpdated, 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(); @@ -32,5 +32,5 @@ void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) cards.at(x + 1)->setCardRef({}); } } - card->resetState(); + card->resetState(false); } \ No newline at end of file diff --git a/cockatrice/src/game/zones/pile_zone_logic.h b/cockatrice/src/game/zones/pile_zone_logic.h index ed6841b75..662b0377f 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.h +++ b/cockatrice/src/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/cockatrice/src/game/zones/stack_zone_logic.cpp index 454274971..ccf0d32f3 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.cpp +++ b/cockatrice/src/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/cockatrice/src/game/zones/stack_zone_logic.h index 657d44ecf..ac6c4617c 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.h +++ b/cockatrice/src/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/cockatrice/src/game/zones/table_zone_logic.cpp index 83e5e55eb..7a6e74adf 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -1,7 +1,7 @@ #include "table_zone_logic.h" -#include "../../game_graphics/board/card_item.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "libcockatrice/card/card_info.h" #include @@ -19,7 +19,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()) { @@ -31,9 +31,9 @@ void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) card->setGridPoint(QPoint(_x, _y)); } -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(); diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/cockatrice/src/game/zones/table_zone_logic.h index 353a497e9..c828d8b91 100644 --- a/cockatrice/src/game/zones/table_zone_logic.h +++ b/cockatrice/src/game/zones/table_zone_logic.h @@ -45,7 +45,7 @@ public: 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. @@ -53,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/cockatrice/src/game/zones/view_zone_logic.cpp index f5e597abe..5e5e0b5fe 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -1,8 +1,8 @@ #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" @@ -77,7 +77,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 @@ -146,8 +146,8 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) return; } - CardItem *card = cards.takeAt(position); - card->getState()->deleteView(); + 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. @@ -170,7 +170,7 @@ void ZoneViewZoneLogic::requestClose() void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { - card->getState()->deleteView(); + card->deleteView(); } cards.clear(); emit cardCountChanged(); diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/cockatrice/src/game/zones/view_zone_logic.h index 71261e204..66d4d4791 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/cockatrice/src/game/zones/view_zone_logic.h @@ -92,7 +92,7 @@ public: PendingCommand *requestZoneDump(); 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/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 0b2b3cf82..9a308ba51 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -71,6 +71,10 @@ CardItem::CardItem(PlayerLogic *_owner, 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); } @@ -233,7 +237,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) if (state->getAttachedTo()->getZone() == nullptr) { deleteLater(); } else { - emit state->getAttachedTo()->getZone()->cardAdded(this); + emit state->getAttachedTo()->getZone()->cardAdded(state); state->getAttachedTo()->addAttachedCard(this); if (state->getZone() != state->getAttachedTo()->getZone()) { state->getAttachedTo()->getZone()->reorganizeCards(); @@ -245,7 +249,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) if (state->getZone() == nullptr) { deleteLater(); } else { - emit state->getZone()->cardAdded(this); + emit state->getZone()->cardAdded(state); } } @@ -467,7 +471,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); diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index f54e3ae28..3422ce28f 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -137,7 +137,8 @@ static void selectCardsInZone( return; } - for (auto &cardItem : zone->getCards()) { + for (auto *cardState : zone->getCards()) { + auto *cardItem = cardState == nullptr ? nullptr : qobject_cast(cardState->parent()); if (cardItem && filter(cardItem)) { cardItem->setSelected(true); } @@ -596,7 +597,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; } @@ -607,7 +612,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) { diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 4a9750fca..ab244dc0b 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -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/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 419816087..14c0dba1c 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -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/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index 8ab942a67..b29ad3666 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -37,17 +37,26 @@ void CardZone::invalidateItems() const QList &CardZone::cardItems() const { if (itemsDirty) { - items = getLogic()->getCards(); + items.clear(); + for (auto *cardState : getLogic()->getCards()) { + if (auto *cardItem = qobject_cast(cardState->parent())) { + items.append(cardItem); + } + } itemsDirty = false; } return items; } -void CardZone::onCardAdded(CardItem *addedCard) +void CardZone::onCardAdded(CardState *addedCard) { - addedCard->setParentItem(this); - addedCard->setVisible(true); - addedCard->update(); + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (addedItem == nullptr) { + return; + } + addedItem->setParentItem(this); + addedItem->setVisible(true); + addedItem->update(); } void CardZone::retranslateUi() diff --git a/cockatrice/src/game_graphics/zones/card_zone.h b/cockatrice/src/game_graphics/zones/card_zone.h index 950b54446..7568cf25f 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -16,6 +16,7 @@ class CardDragItem; class CardItem; +class CardState; class CardZoneLogic; class QAction; class QGraphicsItem; @@ -23,6 +24,8 @@ class QMenu; class QPoint; template class QList; +class CardItem; + inline Q_LOGGING_CATEGORY(CardZoneLog, "card_zone"); /** @@ -55,7 +58,7 @@ 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); public: enum diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 7696ceafd..61946bea1 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -87,12 +87,13 @@ void PileZone::handleDropEvent(const QList &dragItems, CardZoneL getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); } -void PileZone::onCardAdded(CardItem *addedCard) +void PileZone::onCardAdded(CardState *addedCard) { CardZone::onCardAdded(addedCard); - if (addedCard) { - addedCard->setPos(0, 0); - addedCard->setVisible(false); + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (addedItem) { + addedItem->setPos(0, 0); + addedItem->setVisible(false); } } diff --git a/cockatrice/src/game_graphics/zones/pile_zone.h b/cockatrice/src/game_graphics/zones/pile_zone.h index 5dc02a0e4..fa510ad52 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.h +++ b/cockatrice/src/game_graphics/zones/pile_zone.h @@ -33,7 +33,7 @@ public: [[nodiscard]] QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void reorganizeCards() override; - void onCardAdded(CardItem *addedCard) 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 9b0c4e60a..7a74c5342 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -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); } 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/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 7d47b3259..27041e04c 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -95,7 +95,7 @@ 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()) { @@ -107,11 +107,11 @@ void ZoneViewZone::initializeCards(const QList &cardLis 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(); } @@ -127,7 +127,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); @@ -145,7 +145,7 @@ void ZoneViewZone::reorganizeCards() for (auto card : cardItems()) { if (filterString.check(card->getCard().getCardPtr())) { card->show(); - cardsToDisplay.append(card); + cardsToDisplay.append(card->getState()); } else { card->hide(); } @@ -213,7 +213,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 @@ -228,8 +229,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); } @@ -254,11 +255,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)}; From 7ca23430cdedc6b4fea9ea6dfca579f3269bc327 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:14 +0100 Subject: [PATCH 161/247] [Refactor] Retype PlayerActions onto CardState (#7435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Retype card containers and zone logic onto CardState * [Refactor] Retype PlayerActions onto CardState --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.h | 2 +- cockatrice/src/game/player/player_actions.cpp | 96 +++++++++---------- cockatrice/src/game/player/player_actions.h | 86 ++++++++--------- .../src/game/player/player_event_handler.cpp | 2 +- cockatrice/src/game/zones/pile_zone_logic.cpp | 4 - .../src/game/zones/table_zone_logic.cpp | 5 - cockatrice/src/game/zones/view_zone_logic.cpp | 1 - .../src/game_graphics/board/arrow_item.cpp | 4 +- .../src/game_graphics/board/card_item.cpp | 2 +- cockatrice/src/game_graphics/game_scene.cpp | 15 ++- cockatrice/src/game_graphics/game_scene.h | 1 + .../game_graphics/player/menu/card_menu.cpp | 11 ++- .../game_graphics/player/menu/move_menu.cpp | 2 +- .../src/game_graphics/player/menu/pt_menu.cpp | 23 ++--- .../player/menu/utility_menu.cpp | 2 +- .../game_graphics/player/player_dialogs.cpp | 10 +- .../src/game_graphics/player/player_dialogs.h | 2 +- .../src/game_graphics/zones/table_zone.cpp | 6 +- 18 files changed, 138 insertions(+), 136 deletions(-) diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 505bfc1b5..4bf8c120e 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -19,7 +19,7 @@ class GameMetaInfo; class GameReplay; class GameState; class PlayerManager; - +class CardState; class AbstractGame : public QObject { Q_OBJECT diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index c0f4ef11d..4ccf25ca4 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -5,6 +5,7 @@ #include "../../game_graphics/dialogs/dlg_create_token.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" @@ -91,7 +92,7 @@ PlayerActions::PlayerActions(PlayerLogic *_player) connect(moveTopCardTimer, &QTimer::timeout, [this]() { actMoveTopCardToPlay(); }); } -void PlayerActions::playCard(CardItem *card, bool faceDown) +void PlayerActions::playCard(CardState *card, bool faceDown) { if (card == nullptr) { return; @@ -144,7 +145,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; @@ -177,7 +178,7 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) sendGameCommand(cmd); } -void PlayerActions::attachCards(CardItem *startCard, const CardItem *targetCard) +void PlayerActions::attachCards(CardState *startCard, const CardState *targetCard) { if (targetCard->getAttachedTo() || targetCard->getZone()->getName() != ZoneNames::TABLE) { return; @@ -197,7 +198,7 @@ void PlayerActions::attachCards(CardItem *startCard, const CardItem *targetCard) sendGameCommand(cmd); } -void PlayerActions::createArrow(CardItem *startCard, const ArrowData &arrow, int deleteInPhase) +void PlayerActions::createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase) { Command_CreateArrow cmd; cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(arrow.color)); @@ -603,7 +604,7 @@ void PlayerActions::moveTopCardsUntil(const QString &expr, MoveTopCardsUntilOpti } } -void PlayerActions::moveOneCardUntil(CardItem *card) +void PlayerActions::moveOneCardUntil(CardState *card) { moveTopCardTimer->stop(); @@ -1000,7 +1001,7 @@ void PlayerActions::actCreatePredefinedToken() actCreateAnotherToken(); } -void PlayerActions::actCreateRelatedCard(const CardItem *sourceCard, int index) +void PlayerActions::actCreateRelatedCard(const CardState *sourceCard, int index) { if (!sourceCard) { return; @@ -1012,7 +1013,7 @@ void PlayerActions::actCreateRelatedCard(const CardItem *sourceCard, int index) actRequestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -void PlayerActions::actCreateAllRelatedCards(const CardItem *sourceCard) +void PlayerActions::actCreateAllRelatedCards(const CardState *sourceCard) { if (!sourceCard) { return; @@ -1099,13 +1100,13 @@ void PlayerActions::actCreateAllRelatedCards(const CardItem *sourceCard) } } -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) { @@ -1157,7 +1158,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; @@ -1177,7 +1178,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, @@ -1263,7 +1264,7 @@ void PlayerActions::actRequestMoveCardXCardsFromTopDialog() emit requestMoveCardXCardsFromTopDialog(defaultNumberTopCardsToPlaceBelow, deckSize); } -void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) +void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) { defaultNumberTopCardsToPlaceBelow = number; @@ -1297,7 +1298,7 @@ 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(); @@ -1330,7 +1331,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; @@ -1363,7 +1364,7 @@ void PlayerActions::actResetPT(QList selectedCards) } } -void PlayerActions::actRequestSetPTDialog(QList selectedCards) +void PlayerActions::actRequestSetPTDialog(QList selectedCards) { QString oldPT; @@ -1376,7 +1377,7 @@ 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(); @@ -1416,42 +1417,42 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -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); } @@ -1474,7 +1475,7 @@ void PlayerActions::actSetCounter(int counterId, int value) sendGameCommand(cmd); } -void PlayerActions::actReduceLifeByPower(QList selectedCards) +void PlayerActions::actReduceLifeByPower(QList selectedCards) { // find life counter auto lifeCounter = player->getLifeCounter(); @@ -1499,7 +1500,7 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) sendGameCommand(prepareGameCommand(cmd)); } -void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) +void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) { QString oldAnnotation; for (auto card : selectedCards) { @@ -1511,7 +1512,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) { @@ -1525,7 +1526,7 @@ void PlayerActions::actSetAnnotation(QList selectedCards, const QStr sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actUnattach(QList selectedCards) +void PlayerActions::actUnattach(QList selectedCards) { QList commandList; for (auto card : selectedCards) { @@ -1541,17 +1542,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) { @@ -1577,7 +1578,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"; @@ -1589,7 +1590,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) { @@ -1610,12 +1611,12 @@ 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 for (auto *state : player->getTableZone()->getCards()) { - cardsToUpdate.append(qobject_cast(state->parent())); + cardsToUpdate.append(state); } } @@ -1659,7 +1660,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 @@ -1674,27 +1675,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())) { - card->getZone()->removeCard(card->getState()); + 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(); @@ -1785,9 +1785,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) { @@ -2079,10 +2079,10 @@ PendingCommand *PlayerActions::prepareZoneDump(CardZoneLogic *zone, int numberCa return prepareGameCommand(cmd); } -void PlayerActions::actToggleTapped(const QString &zoneName, const QList &cards, bool tapped) +void PlayerActions::actToggleTapped(const QString &zoneName, const QList &cards, bool tapped) { QList cmdList; - for (CardItem *card : cards) { + for (CardState *card : cards) { if (card->getTapped() != tapped) { auto *cmd = new Command_SetCardAttr; cmd->set_zone(zoneName.toStdString()); diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index daee72d2f..6875f865c 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -8,7 +8,6 @@ #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 "../board/card_list.h" #include "card_menu_action_type.h" #include "card_move_request.h" @@ -37,6 +36,7 @@ class Message; } } // namespace google +class CardState; class CardZoneLogic; class Command_MoveCard; class PendingCommand; @@ -84,14 +84,14 @@ public: * @param cards Cards the view wants toggled. * @param tapped Desired tapped state. */ - void actToggleTapped(const QString &zoneName, const QList &cards, bool tapped); + 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(CardItem *startCard, const CardItem *targetCard); + 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. @@ -103,9 +103,9 @@ public: * @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(CardItem *startCard, const ArrowData &arrow, int deleteInPhase); + void createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase); - void moveOneCardUntil(CardItem *card); + void moveOneCardUntil(CardState *card); void stopMoveTopCardsUntil(); [[nodiscard]] bool isMovingCardsUntil() const @@ -134,7 +134,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); @@ -147,8 +147,8 @@ signals: 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(); @@ -157,9 +157,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; @@ -179,9 +179,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(); @@ -230,29 +230,29 @@ public slots: void actOpenDeckInDeckEditor(); void actCreatePredefinedToken(); - void actCreateRelatedCard(const CardItem *sourceCard, int index); - void actCreateAllRelatedCards(const CardItem *sourceCard); + 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 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 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); /** @brief Adjusts a player counter by a signed delta. */ void actIncrementCounter(int counterId, int delta); @@ -260,18 +260,18 @@ public slots: /** @brief Sets a player counter to an absolute value. */ void actSetCounter(int counterId, int value); - void actReduceLifeByPower(QList selectedCards); + void actReduceLifeByPower(QList selectedCards); - void actRequestSetAnnotationDialog(QList selectedCards); - void actSetAnnotation(QList selectedCards, const QString &annotation); - void actReveal(QList selectedCards, QAction *action); + 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; @@ -292,18 +292,18 @@ private: bool lastRelatedCreationSucceeded = false; - void createCard(const CardItem *sourceCard, + 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/cockatrice/src/game/player/player_event_handler.cpp index 4698aeced..8b2ea0024 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -396,7 +396,7 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (player->getPlayerActions()->isMovingCardsUntil() && startZoneString == ZoneNames::DECK && targetZone->getName() == ZoneNames::STACK) { - player->getPlayerActions()->moveOneCardUntil(toCardItem(card)); + player->getPlayerActions()->moveOneCardUntil(card); } } diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/cockatrice/src/game/zones/pile_zone_logic.cpp index 42ef4f0b2..e33d8fe79 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/cockatrice/src/game/zones/pile_zone_logic.cpp @@ -1,11 +1,7 @@ #include "pile_zone_logic.h" -#include "../board/card_list.h" #include "../board/card_state.h" -class PlayerLogic; -class QObject; - PileZoneLogic::PileZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index 7a6e74adf..834794268 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -1,14 +1,9 @@ #include "table_zone_logic.h" -#include "../board/card_list.h" #include "../board/card_state.h" -#include "libcockatrice/card/card_info.h" #include -class PlayerLogic; -class QObject; - TableZoneLogic::TableZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index 5e5e0b5fe..a70b8e3d1 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -1,7 +1,6 @@ #include "view_zone_logic.h" #include "../../client/settings/cache_settings.h" -#include "../board/card_list.h" #include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_logic.h" diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index 90a49819a..cd02a6b1d 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -444,7 +444,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) startCard->playCard(false); } - player->getPlayerActions()->createArrow(startCard, arrow, deleteInPhase); + player->getPlayerActions()->createArrow(startCard->getState(), arrow, deleteInPhase); } delArrow(); @@ -536,7 +536,7 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) auto *startCard = qgraphicsitem_cast(startItem); auto *targetCard = qgraphicsitem_cast(targetItem); if (startCard && targetCard) { - player->getPlayerActions()->attachCards(startCard, targetCard); + player->getPlayerActions()->attachCards(startCard->getState(), targetCard->getState()); } } diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 9a308ba51..e56b5848c 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -442,7 +442,7 @@ void CardItem::playCard(bool faceDown) emit playSelected(this); } } else { - state->getZone()->getPlayer()->getPlayerActions()->playCard(this, faceDown); + state->getZone()->getPlayer()->getPlayerActions()->playCard(state, faceDown); } } } diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 3422ce28f..3da4ba7cb 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -113,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; @@ -235,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) @@ -246,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) @@ -257,7 +266,7 @@ void GameScene::hideSelected(AbstractCardItem *card) if (!card->getOwner()) { return; } - card->getOwner()->getPlayerActions()->actHide(selectedCards()); + card->getOwner()->getPlayerActions()->actHide(selectedCardStates()); } /** diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index ca6441e91..5856d718d 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -113,6 +113,7 @@ 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); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 2174be3d8..2dcb82bb6 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -86,7 +86,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts 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) { @@ -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(); @@ -486,8 +486,9 @@ void CardMenu::addRelatedCardActions() auto *createRelated = new QAction(text, this); const int relatedIndex = index++; - connect(createRelated, &QAction::triggered, this, - [this, playerActions, relatedIndex] { playerActions->actCreateRelatedCard(card, relatedIndex); }); + connect(createRelated, &QAction::triggered, this, [this, playerActions, relatedIndex] { + playerActions->actCreateRelatedCard(card->getState(), relatedIndex); + }); addAction(createRelated); } @@ -497,7 +498,7 @@ void CardMenu::addRelatedCardActions() SettingsCache::instance().shortcuts().getShortcut("Player/aCreateRelatedTokens")); } connect(createRelatedCards, &QAction::triggered, this, - [this, playerActions] { playerActions->actCreateAllRelatedCards(card); }); + [this, playerActions] { playerActions->actCreateAllRelatedCards(card->getState()); }); addAction(createRelatedCards); } } diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 80d748e19..9802c5c3a 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -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/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index b4f852b43..f07d73aef 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -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/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 7c18b3f4c..7332c7807 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -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 674b31ffa..e60a53a80 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -197,7 +197,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) { @@ -252,14 +252,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); @@ -274,7 +274,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); @@ -292,7 +292,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..9dc960a1c 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.h +++ b/cockatrice/src/game_graphics/player/player_dialogs.h @@ -52,7 +52,7 @@ 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); diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index c1e3b9f76..2fcff1d8a 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -266,16 +266,16 @@ void TableZone::reorganizeCards() void TableZone::toggleTapped() { - QList selectedCards; + QList selectedCards; for (auto *item : scene()->selectedItems()) { auto *card = qgraphicsitem_cast(item); if (card && card->getZone()->getName() == ZoneNames::TABLE) { - selectedCards.append(card); + selectedCards.append(card->getState()); } } const bool tapAll = std::any_of(selectedCards.begin(), selectedCards.end(), - [](const CardItem *card) { return !card->getTapped(); }); + [](const CardState *card) { return !card->getTapped(); }); getLogic()->getPlayer()->getPlayerActions()->actToggleTapped(getLogic()->getName(), selectedCards, tapAll); } From 78964779c62893563da678a666adeac7ce0779ae Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:15 +0100 Subject: [PATCH 162/247] [Refactor] Move card item creation out of game logic (#7436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move card item creation out of game logic * [Game] Rename signal to createCardRequested per review * [Game] Move attachment bookkeeping onto CardState CardState::setAttachedTo now maintains the attachedCards graph, resets the grid point and emits the zone events itself, so game logic no longer needs to reach into the CardItem graphics item. CardItem::setAttachedTo delegates to the state and keeps only the view teardown (deleteLater). --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.h | 1 - cockatrice/src/game/board/card_state.cpp | 28 +++++++ cockatrice/src/game/board/card_state.h | 10 +-- cockatrice/src/game/player/player_actions.cpp | 1 - .../src/game/player/player_event_handler.cpp | 83 ++++++++++--------- .../src/game/player/player_event_handler.h | 7 +- cockatrice/src/game/player/player_logic.cpp | 32 +++---- cockatrice/src/game/player/player_logic.h | 4 +- cockatrice/src/game/zones/card_zone_logic.cpp | 23 +++-- cockatrice/src/game/zones/card_zone_logic.h | 5 +- cockatrice/src/game/zones/view_zone_logic.cpp | 2 +- .../src/game_graphics/board/card_item.cpp | 49 ++++------- .../src/game_graphics/board/card_item.h | 6 +- cockatrice/src/game_graphics/game_scene.cpp | 12 ++- cockatrice/src/game_graphics/game_scene.h | 2 +- .../game_graphics/player/menu/player_menu.cpp | 8 +- .../game_graphics/player/menu/player_menu.h | 4 +- .../src/game_graphics/zones/card_zone.cpp | 9 ++ .../src/game_graphics/zones/card_zone.h | 3 + .../src/game_graphics/zones/table_zone.cpp | 7 +- 20 files changed, 159 insertions(+), 137 deletions(-) diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 4bf8c120e..00a95a3b8 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -11,7 +11,6 @@ #include #include -class CardItem; class AbstractClient; class CardState; class GameEventHandler; diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index df7963e00..7e8c079a4 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -1,5 +1,7 @@ #include "card_state.h" +#include "../zones/card_zone_logic.h" + #include #include #include @@ -206,7 +208,33 @@ 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/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index bce5a298d..fa1e81393 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -12,8 +12,6 @@ #include class CardZoneLogic; -class CardItem; - class CardState : public QObject { Q_OBJECT @@ -35,7 +33,7 @@ private: QString color; CardState *attachedTo = nullptr; - QList attachedCards; + QList attachedCards; CardZoneLogic *zone = nullptr; signals: @@ -226,17 +224,17 @@ public: void setAttachedTo(CardState *_attachedTo); - void addAttachedCard(CardItem *card) + void addAttachedCard(CardState *card) { attachedCards.append(card); } - void removeAttachedCard(CardItem *card) + void removeAttachedCard(CardState *card) { attachedCards.removeOne(card); } - const QList &getAttachedCards() const + const QList &getAttachedCards() const { return attachedCards; } diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 4ccf25ca4..b7e59b4e3 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1,7 +1,6 @@ #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 "../abstract_game.h" #include "../board/arrow_data.h" diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index 8b2ea0024..6588547da 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -1,9 +1,9 @@ #include "player_event_handler.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 "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" @@ -27,6 +27,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -49,15 +52,11 @@ #include #include #include +#include #include #include #include -static CardItem *toCardItem(CardState *card) -{ - return card == nullptr ? nullptr : qobject_cast(card->parent()); -} - PlayerEventHandler::PlayerEventHandler(PlayerLogic *_player) : QObject(_player), player(_player) { connect(this, &PlayerEventHandler::requestCardMenuUpdate, player, &PlayerLogic::requestCardMenuUpdate); @@ -160,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->getState(), 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, @@ -276,7 +281,7 @@ void PlayerEventHandler::eventSetCardCounter(const Event_SetCardCounter &event) int oldValue = card->getCounters().value(event.counter_id(), 0); card->setCounter(event.counter_id(), event.counter_value()); - emit requestCardMenuUpdate(toCardItem(card)); + emit requestCardMenuUpdate(card); emit logSetCardCounter(player, card->getName(), event.counter_id(), event.counter_value(), oldValue); } @@ -362,22 +367,17 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (card->getAttachedTo() && (startZone != targetZone)) { CardState *parentCard = card->getAttachedTo(); - // Route through CardItem so the old parent's attachedCards list is cleaned up: CardState::setAttachedTo - // only repoints the pointer, leaving a dangling entry that reorganizeCards/prepareDelete would dereference. - if (CardItem *item = toCardItem(card)) { - item->setAttachedTo(nullptr); - } else { - card->setAttachedTo(nullptr); - } + // CardState::setAttachedTo removes the card from the old parent's attachedCards list and emits the zone events. + card->setAttachedTo(nullptr); parentCard->getZone()->reorganizeCards(); } card->setId(event.new_card_id()); card->setFaceDown(event.face_down()); if (startZone != targetZone) { - const QList &attachedCards = card->getAttachedCards(); + const QList &attachedCards = card->getAttachedCards(); for (auto attachedCard : attachedCards) { - emit targetZone->cardAdded(attachedCard->getState()); + emit targetZone->cardAdded(attachedCard); } } @@ -391,8 +391,8 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv targetZone->addCard(card, true, x, y); - emit cardZoneChanged(toCardItem(card), startZone == targetZone); - emit requestCardMenuUpdate(toCardItem(card)); + emit cardZoneChanged(card, startZone == targetZone); + emit requestCardMenuUpdate(card); if (player->getPlayerActions()->isMovingCardsUntil() && startZoneString == ZoneNames::DECK && targetZone->getName() == ZoneNames::STACK) { @@ -419,7 +419,7 @@ void PlayerEventHandler::eventFlipCard(const Event_FlipCard &event) emit logFlipCard(player, card->getName(), event.face_down()); card->setFaceDown(event.face_down()); - emit requestCardMenuUpdate(toCardItem(card)); + emit requestCardMenuUpdate(card); } void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) @@ -434,10 +434,18 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) 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()); @@ -473,14 +481,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) CardState *oldParent = startCard->getAttachedTo(); - // Route through CardItem::setAttachedTo to keep the attachedCards graph, grid-x reset and zone emits intact: - // CardState::setAttachedTo alone only repoints the pointer, so the target would never list the card and the old - // parent would keep a dangling pointer. - if (CardItem *startItem = toCardItem(startCard)) { - startItem->setAttachedTo(toCardItem(targetCard)); - } else { - startCard->setAttachedTo(targetCard); - } + startCard->setAttachedTo(targetCard); startZone->reorganizeCards(); if ((startZone != targetZone) && targetZone) { @@ -495,7 +496,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) } else { emit logUnattachCard(player, startCard->getName()); } - emit requestCardMenuUpdate(toCardItem(startCard)); + emit requestCardMenuUpdate(startCard); } void PlayerEventHandler::eventDrawCards(const Event_DrawCards &event) diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 6b721bf61..d253deef0 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -27,7 +27,6 @@ #include #include -class CardItem; class CardState; class CardZoneLogic; class PlayerLogic; @@ -62,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. @@ -267,8 +266,8 @@ signals: void logAlwaysLookAtTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); /** @} */ - void cardZoneChanged(CardItem *card, bool sameZone); - void requestCardMenuUpdate(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. * diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index f6a3a50e7..ce4e8a95e 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/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,11 +34,6 @@ #include #include -static CardItem *toCardItem(CardState *card) -{ - return card == nullptr ? nullptr : qobject_cast(card->parent()); -} - PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), @@ -177,22 +173,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))->getState(), 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->getState(), false, cardInfo.x(), cardInfo.y()); - } else { - zone->addCard(card->getState(), 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()) { @@ -232,13 +226,7 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) continue; } - // Route through CardItem::setAttachedTo to maintain the attachedCards graph and reparenting; - // CardState::setAttachedTo alone only repoints the pointer. - if (CardItem *startItem = toCardItem(startCard)) { - startItem->setAttachedTo(toCardItem(targetCard)); - } else { - startCard->setAttachedTo(targetCard); - } + startCard->setAttachedTo(targetCard); } } } @@ -250,7 +238,7 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) } } -void PlayerLogic::addCard(CardItem *card) +void PlayerLogic::addCard(AbstractCardItem *card) { emit newCardAdded(card); } diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index ceaeff10b..196ee8ebe 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -73,7 +73,7 @@ signals: /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ void playmatChanged(); void newCardAdded(AbstractCardItem *card); - void requestCardMenuUpdate(CardItem *card); + void requestCardMenuUpdate(CardState *card); void counterAdded(CounterState *state); void counterRemoved(int counterId); void rearrangeCounters(); @@ -103,7 +103,7 @@ public: void processPlayerInfo(const ServerInfo_Player &info); void processCardAttachment(const ServerInfo_Player &info); - void addCard(CardItem *c); + void addCard(AbstractCardItem *c); void deleteCard(CardState *c); bool clearCardsToDelete(); diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/cockatrice/src/game/zones/card_zone_logic.cpp index 8cce9f957..522fb5e04 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/cockatrice/src/game/zones/card_zone_logic.cpp @@ -1,7 +1,9 @@ #include "card_zone_logic.h" #include "../../client/translation.h" -#include "../../game_graphics/board/card_item.h" +#include "../../game_graphics/zones/card_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" @@ -50,10 +52,15 @@ void CardZoneLogic::addCard(CardState *card, const bool reorganize, const int x, for (auto *viewLogic : views) { if (viewLogic->prepareAddCard(x)) { - auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); - copy->setFaceDown(card->getFaceDown()); - - viewLogic->addCard(copy->getState(), reorganize, x, y); + 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); } } @@ -168,9 +175,9 @@ void CardZoneLogic::clearContents() 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) { - emit attachedCard->getZone()->cardAdded(attachedCard->getState()); + const QList &attachedCards = card->getAttachedCards(); + for (CardState *attachedCard : attachedCards) { + emit attachedCard->getZone()->cardAdded(attachedCard); } } diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/cockatrice/src/game/zones/card_zone_logic.h index 2a6b50c5c..327b78f61 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -14,10 +14,9 @@ #include #include #include +#include #include -class CardItem; - inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; @@ -33,6 +32,8 @@ class CardZoneLogic : public QObject signals: 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(); diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index a70b8e3d1..3fcf8d075 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -37,7 +37,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. diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index e56b5848c..8030331eb 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -89,14 +89,18 @@ void CardItem::prepareDelete() owner = nullptr; } - const QList &attachedCards = state->getAttachedCards(); - 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); } } @@ -225,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); - state->setGridPoint(QPoint(-1, state->getGridPoint().y())); - state->setAttachedTo(_attachedTo == nullptr ? nullptr : _attachedTo->getState()); - 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(state); - 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(state); - } - } - - 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(); } } diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 79c3dcdbd..706c00195 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -120,15 +120,15 @@ public: return state->getAttachedTo(); } void setAttachedTo(CardItem *_attachedTo); - void addAttachedCard(CardItem *card) + void addAttachedCard(CardState *card) { state->addAttachedCard(card); } - void removeAttachedCard(CardItem *card) + void removeAttachedCard(CardState *card) { state->removeAttachedCard(card); } - [[nodiscard]] const QList &getAttachedCards() const + [[nodiscard]] const QList &getAttachedCards() const { return state->getAttachedCards(); } diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 3da4ba7cb..e7362871f 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -205,7 +205,7 @@ void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool sele auto *owner = card->getOwner(); if (selected) { - owner->requestCardMenuUpdate(card); + owner->requestCardMenuUpdate(card->getState()); return; } @@ -231,7 +231,7 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen setActiveCard(card); - if (auto *menu = view->getPlayerMenu()->updateCardMenu(card)) { + if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) { menu->popup(screenPos); } } @@ -650,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 { diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 5856d718d..3f7d40ed0 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -283,7 +283,7 @@ 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. * diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 7e69df66e..10e7a7ddc 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -87,22 +87,24 @@ void PlayerMenu::setMenusForGraphicItems() } } -QMenu *PlayerMenu::updateCardMenu(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->getGameScene()->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 27a5efe7e..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(CardItem *card); + QMenu *updateCardMenu(CardState *card); private slots: void refreshShortcuts(); diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index b29ad3666..0f91c10b8 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -24,6 +24,7 @@ CardZone::CardZone(CardZoneLogic *_logic, QGraphicsItem *parent) 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); @@ -59,6 +60,14 @@ void CardZone::onCardAdded(CardState *addedCard) 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 (auto *card : cardItems()) { diff --git a/cockatrice/src/game_graphics/zones/card_zone.h b/cockatrice/src/game_graphics/zones/card_zone.h index 7568cf25f..ea77cb057 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -22,6 +22,7 @@ class QAction; class QGraphicsItem; class QMenu; class QPoint; +class ServerInfo_Card; template class QList; class CardItem; @@ -59,6 +60,8 @@ public slots: * connection in CardZone's constructor dispatches through the vtable. */ 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 diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 2fcff1d8a..8cb209c5a 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -248,11 +248,14 @@ void TableZone::reorganizeCards() cardItems()[i]->setPos(actualX, actualY); cardItems()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY)); - QListIterator attachedCardIterator(cardItems()[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); From 345b0e8ba9d2161b5d7cfc3d18a1ec03238874a5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:15 +0100 Subject: [PATCH 163/247] [Refactor] Move game logic into libcockatrice_game (#7437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move game logic into libcockatrice_game * [Refactor] Include card_state.h in player_actions.h * [Game] Inject settings providers into libcockatrice_game, drop Widgets * [Game] Follow base rename to createCardRequested after rebase * [Game] Drop unused CardItem forward declaration * [Game] Fix includes after moving game logic into libcockatrice_game --------- Co-authored-by: Lukas Brübach --- CMakeLists.txt | 3 + cockatrice/CMakeLists.txt | 23 +--- .../settings_card_preference_provider.h | 7 +- cockatrice/src/game/replay.h | 26 ----- .../game_graphics/board/abstract_card_item.h | 9 +- .../game_graphics/board/abstract_counter.cpp | 8 +- .../src/game_graphics/board/arrow_item.cpp | 10 +- .../src/game_graphics/board/arrow_item.h | 2 +- .../game_graphics/board/arrow_registry.cpp | 2 +- .../src/game_graphics/board/arrow_registry.h | 3 +- .../src/game_graphics/board/card_item.cpp | 22 ++-- .../src/game_graphics/board/card_item.h | 2 +- .../deckview/deck_view_container.cpp | 6 +- .../game_graphics/dialogs/dlg_create_token.h | 3 +- .../dialogs/dlg_move_top_cards_until.h | 3 +- cockatrice/src/game_graphics/game_scene.cpp | 16 +-- cockatrice/src/game_graphics/game_scene.h | 4 +- cockatrice/src/game_graphics/hand_counter.cpp | 4 +- .../game_graphics/log/message_log_widget.cpp | 20 ++-- .../game_graphics/player/menu/card_menu.cpp | 16 +-- .../player/menu/custom_zone_menu.cpp | 4 +- .../game_graphics/player/menu/grave_menu.cpp | 12 +- .../game_graphics/player/menu/hand_menu.cpp | 14 +-- .../player/menu/library_menu.cpp | 14 +-- .../game_graphics/player/menu/move_menu.cpp | 6 +- .../game_graphics/player/menu/player_menu.cpp | 8 +- .../src/game_graphics/player/menu/pt_menu.cpp | 4 +- .../game_graphics/player/menu/rfg_menu.cpp | 8 +- .../game_graphics/player/menu/say_menu.cpp | 4 +- .../player/menu/sideboard_menu.cpp | 6 +- .../player/menu/utility_menu.cpp | 6 +- .../game_graphics/player/player_dialogs.cpp | 11 +- .../src/game_graphics/player/player_dialogs.h | 2 + .../player/player_graphics_item.cpp | 18 +-- .../game_graphics/player/player_target.cpp | 4 +- .../src/game_graphics/zones/card_zone.cpp | 4 +- .../src/game_graphics/zones/hand_zone.cpp | 10 +- .../src/game_graphics/zones/hand_zone.h | 2 +- .../src/game_graphics/zones/pile_zone.cpp | 12 +- .../src/game_graphics/zones/select_zone.cpp | 4 +- .../src/game_graphics/zones/stack_zone.cpp | 12 +- .../src/game_graphics/zones/table_zone.cpp | 12 +- .../src/game_graphics/zones/table_zone.h | 2 +- .../src/game_graphics/zones/view_zone.cpp | 10 +- .../src/game_graphics/zones/view_zone.h | 2 +- .../game_graphics/zones/view_zone_widget.cpp | 19 ++-- .../widgets/replay/replay_manager.cpp | 2 +- .../interface/widgets/replay/replay_manager.h | 3 +- .../interface/widgets/replay/replay_widget.h | 3 +- .../widgets/server/user/user_context_menu.cpp | 6 +- .../src/interface/widgets/tabs/tab_game.cpp | 27 +++-- .../interface/widgets/tabs/tab_supervisor.cpp | 6 +- libcockatrice_game/CMakeLists.txt | 81 +++++++++++++ .../libcockatrice}/game/abstract_game.cpp | 0 .../libcockatrice}/game/abstract_game.h | 0 .../libcockatrice}/game/board/arrow_data.cpp | 0 .../libcockatrice}/game/board/arrow_data.h | 0 .../libcockatrice}/game/board/card_list.cpp | 0 .../libcockatrice}/game/board/card_list.h | 0 .../libcockatrice}/game/board/card_state.cpp | 0 .../libcockatrice}/game/board/card_state.h | 0 .../game/board/counter_state.cpp | 0 .../libcockatrice}/game/board/counter_state.h | 0 .../libcockatrice}/game/game.cpp | 7 +- .../libcockatrice}/game/game.h | 7 +- .../game/game_event_handler.cpp | 0 .../libcockatrice}/game/game_event_handler.h | 1 - .../libcockatrice}/game/game_meta_info.cpp | 0 .../libcockatrice}/game/game_meta_info.h | 0 .../libcockatrice}/game/game_state.cpp | 0 .../libcockatrice}/game/game_state.h | 0 .../libcockatrice}/game/phase.cpp | 0 .../libcockatrice}/game/phase.h | 0 .../game/player/card_menu_action_type.h | 0 .../game/player/card_move_request.h | 0 .../game/player/event_processing_options.h | 0 .../player/move_top_cards_until_options.h | 0 .../game/player/player_actions.cpp | 57 +++++++--- .../game/player/player_actions.h | 25 ++++- .../game/player/player_event_handler.cpp | 0 .../game/player/player_event_handler.h | 0 .../game/player/player_info.cpp | 0 .../libcockatrice}/game/player/player_info.h | 0 .../game/player/player_logic.cpp | 13 ++- .../libcockatrice}/game/player/player_logic.h | 11 +- .../game/player/player_manager.cpp | 9 +- .../game/player/player_manager.h | 12 +- .../libcockatrice}/game/player/token_info.h | 0 .../libcockatrice}/game/replay.cpp | 9 +- .../libcockatrice/game/replay.h | 32 ++++++ .../libcockatrice/game}/translation.h | 0 .../game/zones/card_zone_algorithms.h | 0 .../game/zones/card_zone_logic.cpp | 3 +- .../game/zones/card_zone_logic.h | 5 +- .../game/zones/hand_zone_logic.cpp | 0 .../game/zones/hand_zone_logic.h | 0 .../game/zones/pile_zone_logic.cpp | 0 .../game/zones/pile_zone_logic.h | 0 .../game/zones/stack_zone_logic.cpp | 0 .../game/zones/stack_zone_logic.h | 0 .../game/zones/table_zone_logic.cpp | 0 .../game/zones/table_zone_logic.h | 0 .../game/zones/view_zone_logic.cpp | 14 ++- .../game/zones/view_zone_logic.h | 6 + tests/CMakeLists.txt | 5 + tests/card_zone_algorithms/CMakeLists.txt | 2 +- tests/game/CMakeLists.txt | 28 +++++ tests/game/game_smoke_test.cpp | 106 ++++++++++++++++++ 108 files changed, 598 insertions(+), 281 deletions(-) delete mode 100644 cockatrice/src/game/replay.h create mode 100644 libcockatrice_game/CMakeLists.txt rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/abstract_game.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/abstract_game.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/arrow_data.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/arrow_data.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_list.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_list.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_state.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_state.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/counter_state.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/counter_state.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game.cpp (75%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game.h (69%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_event_handler.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_event_handler.h (99%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_meta_info.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_meta_info.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_state.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_state.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/phase.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/phase.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/card_menu_action_type.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/card_move_request.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/event_processing_options.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/move_top_cards_until_options.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_actions.cpp (97%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_actions.h (91%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_event_handler.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_event_handler.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_info.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_info.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_logic.cpp (95%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_logic.h (94%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_manager.cpp (86%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_manager.h (84%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/token_info.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/replay.cpp (51%) create mode 100644 libcockatrice_game/libcockatrice/game/replay.h rename {cockatrice/src/client => libcockatrice_game/libcockatrice/game}/translation.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/card_zone_algorithms.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/card_zone_logic.cpp (98%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/card_zone_logic.h (97%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/hand_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/hand_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/pile_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/pile_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/stack_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/stack_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/table_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/table_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/view_zone_logic.cpp (92%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/view_zone_logic.h (87%) create mode 100644 tests/game/CMakeLists.txt create mode 100644 tests/game/game_smoke_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 41fe393e7..159c4a24f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -420,6 +420,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) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 2df79e0f6..a4dc96937 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -69,20 +69,15 @@ 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_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 @@ -91,15 +86,10 @@ set(cockatrice_SOURCES 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 @@ -117,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 @@ -573,6 +551,7 @@ target_link_libraries( PUBLIC libcockatrice_card libcockatrice_deck_list libcockatrice_filters + libcockatrice_game libcockatrice_utility libcockatrice_network libcockatrice_models 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/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.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index 01fc4bb0a..eee8ec743 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -7,11 +7,18 @@ #ifndef ABSTRACTCARDITEM_H #define ABSTRACTCARDITEM_H -#include "../../game/board/card_state.h" #include "../animated_item.h" #include "arrow_target.h" #include "graphics_item_type.h" +#include +#include +#include +#include +#include +#include +#include + class PlayerLogic; class CardInfo; class QPainter; diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index 93eb9e011..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,6 +19,10 @@ #include #include #include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index cd02a6b1d..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,6 +22,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index ab11f6d84..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; diff --git a/cockatrice/src/game_graphics/board/arrow_registry.cpp b/cockatrice/src/game_graphics/board/arrow_registry.cpp index fe1c34263..10b3cde2f 100644 --- a/cockatrice/src/game_graphics/board/arrow_registry.cpp +++ b/cockatrice/src/game_graphics/board/arrow_registry.cpp @@ -1,9 +1,9 @@ #include "arrow_registry.h" -#include "../../game/board/arrow_data.h" #include "arrow_item.h" #include +#include void ArrowRegistry::insert(QSharedPointer data, ArrowItem *arrow) { diff --git a/cockatrice/src/game_graphics/board/arrow_registry.h b/cockatrice/src/game_graphics/board/arrow_registry.h index f07c5c469..7103747e8 100644 --- a/cockatrice/src/game_graphics/board/arrow_registry.h +++ b/cockatrice/src/game_graphics/board/arrow_registry.h @@ -1,11 +1,10 @@ #ifndef COCKATRICE_ARROW_REGISTRY_H #define COCKATRICE_ARROW_REGISTRY_H -#include "../../game/board/arrow_data.h" - #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 8030331eb..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 diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 706c00195..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; diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 870392aa6..3bef9e59e 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -2,9 +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_state.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/deck_loader/deck_loader.h" #include "../../interface/widgets/dialogs/dlg_load_deck.h" @@ -34,6 +31,9 @@ #include #include #include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h index c63c5d7b2..323e61829 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -7,12 +7,11 @@ #ifndef DLG_CREATETOKEN_H #define DLG_CREATETOKEN_H -#include "../../game/player/token_info.h" - #include #include #include #include +#include #include class QLabel; 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 3b0fe19ec..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 @@ -7,12 +7,11 @@ #ifndef DLG_MOVE_TOP_CARDS_UNTIL_H #define DLG_MOVE_TOP_CARDS_UNTIL_H -#include "../../game/player/move_top_cards_until_options.h" - #include #include #include #include +#include #include class FilterString; diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index e7362871f..db1040006 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -1,14 +1,6 @@ #include "game_scene.h" #include "../client/settings/cache_settings.h" -#include "../game/abstract_game.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" @@ -41,6 +33,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 3f7d40ed0..0d4db70a8 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -1,8 +1,6 @@ #ifndef GAMESCENE_H #define GAMESCENE_H -#include "../game/board/arrow_data.h" -#include "../game/zones/card_zone_logic.h" #include "animated_item.h" #include "board/arrow_item.h" #include "board/arrow_registry.h" @@ -20,6 +18,8 @@ #include #include #include +#include +#include #include // IWYU pragma: keep 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 ab244dc0b..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 diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 2dcb82bb6..9f00c0cba 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -3,15 +3,7 @@ #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/card_menu_action_type.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 "../player_graphics_item.h" #include "libcockatrice/card/card_info.h" @@ -35,6 +27,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include 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 9802c5c3a..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/card_menu_action_type.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 +#include MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) { diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 10e7a7ddc..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) { diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index f07d73aef..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")) { 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 7332c7807..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) { diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index e60a53a80..a5baa8ca8 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -2,8 +2,6 @@ #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" @@ -18,6 +16,8 @@ #include #include #include +#include +#include #include PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_playerActions) @@ -35,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, @@ -127,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; diff --git a/cockatrice/src/game_graphics/player/player_dialogs.h b/cockatrice/src/game_graphics/player/player_dialogs.h index 9dc960a1c..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; @@ -58,6 +59,7 @@ public slots: 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 14c0dba1c..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 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/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index 0f91c10b8..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; diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 8cd5e4631..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,6 +13,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include 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 61946bea1..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,6 +18,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index 7a74c5342..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 diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index 4c97a1969..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,6 +13,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 8cb209c5a..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,6 +26,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index f3fc0dc5a..8aa4936ad 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -7,7 +7,6 @@ #ifndef TABLEZONE_H #define TABLEZONE_H -#include "../../game/zones/table_zone_logic.h" #include "../animated_item.h" #include "../card_dimensions.h" #include "select_zone.h" @@ -18,6 +17,7 @@ #include #include #include +#include #include class CardDragItem; diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 27041e04c..83c13d467 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,6 +21,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include 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 d6d42c3d9..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,6 +39,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -177,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(); 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/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index c637dd0dd..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(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 06d60a756..3316232a4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,9 +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 "../../intents/contexts/context_connect_to_server.h" #include "../../intents/contexts/context_join_game.h" #include "../../intents/contexts/context_join_room.h" @@ -63,6 +60,9 @@ #include #include #include +#include +#include +#include #include #include #include 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 100% rename from cockatrice/src/game/abstract_game.cpp rename to libcockatrice_game/libcockatrice/game/abstract_game.cpp diff --git a/cockatrice/src/game/abstract_game.h b/libcockatrice_game/libcockatrice/game/abstract_game.h similarity index 100% rename from cockatrice/src/game/abstract_game.h rename to libcockatrice_game/libcockatrice/game/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 100% rename from cockatrice/src/game/board/card_list.cpp rename to libcockatrice_game/libcockatrice/game/board/card_list.cpp diff --git a/cockatrice/src/game/board/card_list.h b/libcockatrice_game/libcockatrice/game/board/card_list.h similarity index 100% rename from cockatrice/src/game/board/card_list.h rename to libcockatrice_game/libcockatrice/game/board/card_list.h diff --git a/cockatrice/src/game/board/card_state.cpp b/libcockatrice_game/libcockatrice/game/board/card_state.cpp similarity index 100% rename from cockatrice/src/game/board/card_state.cpp rename to libcockatrice_game/libcockatrice/game/board/card_state.cpp diff --git a/cockatrice/src/game/board/card_state.h b/libcockatrice_game/libcockatrice/game/board/card_state.h similarity index 100% rename from cockatrice/src/game/board/card_state.h rename to libcockatrice_game/libcockatrice/game/board/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 100% rename from cockatrice/src/game/game_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/game_event_handler.cpp diff --git a/cockatrice/src/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h similarity index 99% rename from cockatrice/src/game/game_event_handler.h rename to libcockatrice_game/libcockatrice/game/game_event_handler.h index 15e6b771c..f7b11780b 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -37,7 +37,6 @@ class AbstractClient; class AbstractGame; class GameEventContainer; -class GameEventContext; class PendingCommand; class PlayerLogic; class Response; 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/player/card_menu_action_type.h b/libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h similarity index 100% rename from cockatrice/src/game/player/card_menu_action_type.h rename to libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h diff --git a/cockatrice/src/game/player/card_move_request.h b/libcockatrice_game/libcockatrice/game/player/card_move_request.h similarity index 100% rename from cockatrice/src/game/player/card_move_request.h rename to libcockatrice_game/libcockatrice/game/player/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/cockatrice/src/game/player/move_top_cards_until_options.h b/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h similarity index 100% rename from cockatrice/src/game/player/move_top_cards_until_options.h rename to libcockatrice_game/libcockatrice/game/player/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 97% rename from cockatrice/src/game/player/player_actions.cpp rename to libcockatrice_game/libcockatrice/game/player/player_actions.cpp index b7e59b4e3..90823fec6 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -1,7 +1,5 @@ #include "player_actions.h" -#include "../../client/settings/cache_settings.h" -#include "../../game_graphics/dialogs/dlg_create_token.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/card_state.h" @@ -47,6 +45,8 @@ #include #include #include +#include +#include #include #include #include @@ -65,8 +65,7 @@ #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,6 +93,26 @@ PlayerActions::PlayerActions(PlayerLogic *_player) connect(moveTopCardTimer, &QTimer::timeout, [this]() { actMoveTopCardToPlay(); }); } +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) { @@ -112,7 +134,7 @@ void PlayerActions::playCard(CardState *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); @@ -214,7 +236,7 @@ void PlayerActions::createArrow(CardState *startCard, const ArrowData &arrow, in // 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 = SettingsCache::instance().userInterface().getPlayToStack(); + bool playToStack = getPlayToStack(); if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || (playToStack && ci->getUiAttributes().tableRow != 0 && startCard->getZone()->getName() != ZoneNames::STACK))) { @@ -409,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; @@ -425,7 +447,7 @@ void PlayerActions::actMulligan(int number) } doMulligan(number); - SettingsCache::instance().userInterface().setStartingHandSize(number); + emit startingHandSizeChosen(number); } void PlayerActions::actMulliganSameSize() @@ -974,13 +996,12 @@ 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 = TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow); @@ -1208,7 +1229,7 @@ void PlayerActions::createCard(const CardState *sourceCard, } cmd.set_pt(cardInfo->getPowTough().toStdString()); - if (SettingsCache::instance().userInterface().getAnnotateTokens()) { + if (getAnnotateTokens()) { cmd.set_annotation(cardInfo->getText().toStdString()); } else { cmd.set_annotation(""); diff --git a/cockatrice/src/game/player/player_actions.h b/libcockatrice_game/libcockatrice/game/player/player_actions.h similarity index 91% rename from cockatrice/src/game/player/player_actions.h rename to libcockatrice_game/libcockatrice/game/player/player_actions.h index 6875f865c..7ed52b9a1 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.h @@ -9,6 +9,7 @@ #define COCKATRICE_PLAYER_ACTIONS_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" @@ -22,7 +23,6 @@ #include #include -class CardItem; class CardRelation; class QAction; class QTimer; @@ -39,8 +39,12 @@ class Message; 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 { @@ -52,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); @@ -143,6 +149,12 @@ 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); @@ -275,6 +287,10 @@ public slots: 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; @@ -292,6 +308,11 @@ private: bool lastRelatedCreationSucceeded = false; + [[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, diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp similarity index 100% rename from cockatrice/src/game/player/player_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp diff --git a/cockatrice/src/game/player/player_event_handler.h b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h similarity index 100% rename from cockatrice/src/game/player/player_event_handler.h rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.h 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 95% rename from cockatrice/src/game/player/player_logic.cpp rename to libcockatrice_game/libcockatrice/game/player/player_logic.cpp index ce4e8a95e..96bd07164 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp @@ -34,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(); } diff --git a/cockatrice/src/game/player/player_logic.h b/libcockatrice_game/libcockatrice/game/player/player_logic.h similarity index 94% rename from cockatrice/src/game/player/player_logic.h rename to libcockatrice_game/libcockatrice/game/player/player_logic.h index 196ee8ebe..d6c4f5187 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.h @@ -37,7 +37,6 @@ // signatures, which the moc-generated code needs to be complete. #include -class CardItem; class CounterState; class QWidget; class ServerInfo_PlayerProperties; @@ -48,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; @@ -93,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(); 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/cockatrice/src/game/player/token_info.h b/libcockatrice_game/libcockatrice/game/player/token_info.h similarity index 100% rename from cockatrice/src/game/player/token_info.h rename to libcockatrice_game/libcockatrice/game/player/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 100% rename from cockatrice/src/game/zones/card_zone_algorithms.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp similarity index 98% rename from cockatrice/src/game/zones/card_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp index 522fb5e04..a160dcc1d 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp @@ -1,12 +1,11 @@ #include "card_zone_logic.h" -#include "../../client/translation.h" -#include "../../game_graphics/zones/card_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" diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h similarity index 97% rename from cockatrice/src/game/zones/card_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h index 327b78f61..d3cd9121f 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h @@ -7,8 +7,8 @@ #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 @@ -21,9 +21,6 @@ inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; class ZoneViewZoneLogic; -class QMenu; -class QAction; -class QPainter; class CardDragItem; class CardZoneLogic : public QObject diff --git a/cockatrice/src/game/zones/hand_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp similarity index 100% rename from cockatrice/src/game/zones/hand_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp diff --git a/cockatrice/src/game/zones/hand_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/hand_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/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 100% rename from cockatrice/src/game/zones/pile_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp diff --git a/cockatrice/src/game/zones/pile_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/pile_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/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 100% rename from cockatrice/src/game/zones/stack_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp diff --git a/cockatrice/src/game/zones/stack_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/stack_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/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 100% rename from cockatrice/src/game/zones/table_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/table_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/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 92% rename from cockatrice/src/game/zones/view_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index 3fcf8d075..88941f12b 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -1,11 +1,11 @@ #include "view_zone_logic.h" -#include "../../client/settings/cache_settings.h" #include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_logic.h" #include +#include #include class PlayerLogic; @@ -23,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. @@ -65,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(); } @@ -152,7 +158,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) // 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; } diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h similarity index 87% rename from cockatrice/src/game/zones/view_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index 66d4d4791..3592db780 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -13,6 +13,7 @@ class PlayerLogic; class QObject; class PendingCommand; +class IInterfaceSettingsProvider; class ZoneViewZoneLogic : public CardZoneLogic { @@ -27,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 @@ -42,6 +45,7 @@ public: bool _revealZone, bool _writeableRevealZone, bool _isReversed, + IInterfaceSettingsProvider *interfaceSettings = nullptr, QObject *parent = nullptr); bool prepareAddCard(int x); @@ -58,6 +62,8 @@ public: /** @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; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 19f9268ee..c3b9a7547 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -487,6 +487,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/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(); +} From 52d3978f8467c65d47e4a71a7741a1eec59f9cd7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:49:05 +0100 Subject: [PATCH 164/247] [Build] Let AUTOMOC process library headers (#7638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 13 libraries using qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) listed their\nheaders twice and depended on a generated source list. Add the headers to the\ntarget sources directly and delete the qt6_wrap_cpp call; AUTOMOC scans them\nand generates the same moc output.\n\nThe change exposed that server_card_counter_test never defined the rng global\nthat the server remote library references; every sibling server test does.\nGive the test the same definition so the target links.\n\nVerification: full client, Oracle, server and test build passed. Co-authored-by: Lukas Brübach --- libcockatrice_card/CMakeLists.txt | 4 +--- libcockatrice_deck_list/CMakeLists.txt | 4 +--- libcockatrice_filters/CMakeLists.txt | 6 ++---- libcockatrice_interfaces/CMakeLists.txt | 4 +--- .../libcockatrice/models/database/CMakeLists.txt | 4 +--- .../libcockatrice/models/deck_list/CMakeLists.txt | 4 +--- .../libcockatrice/network/client/abstract/CMakeLists.txt | 4 +--- .../libcockatrice/network/client/local/CMakeLists.txt | 4 +--- .../libcockatrice/network/client/remote/CMakeLists.txt | 4 +--- .../libcockatrice/network/server/local/CMakeLists.txt | 4 +--- .../libcockatrice/network/server/remote/CMakeLists.txt | 4 +--- libcockatrice_rng/CMakeLists.txt | 4 +--- libcockatrice_settings/CMakeLists.txt | 4 +--- tests/server_card_counter_test.cpp | 4 ++++ 14 files changed, 18 insertions(+), 40 deletions(-) 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_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 224c10942..ffd4c28a1 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -18,11 +18,9 @@ set(HEADERS 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 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_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/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_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/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); From f2c359ba9fa3ed33934fceef40c095701d5c22cd Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:49:45 +0100 Subject: [PATCH 165/247] [CI] Keep the vcpkg cache directory guard unconditional (#7527) The cache action creates its directory only when a prefix matches an entry, and it reads that directory unguarded when it saves, so a scope nobody has written to yet fails the job. Create it up front on every outcome, and fall back to the workspace path when the cache step was skipped and set no path output. Co-authored-by: opencode --- .github/workflows/desktop-build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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" From d4002dc52b4280b8ad32a94be2a2e1a148195855 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:52:11 +0100 Subject: [PATCH 166/247] [Build] Reject APIs deprecated before Qt 6.4 (#7632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the deprecation cutoff after FindQtRuntime so the compiler rejects any API deprecated before the project's minimum Qt version. Qt 6.5 renamed the switch, so the fallback name is used on 6.4. The full client, Oracle, server and test build passes with the cutoff enabled. Co-authored-by: Lukas Brübach --- CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 159c4a24f..4cdc19911 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 From ae43659b914925bd8a747d2fbc52990d0a5e27a4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:53:00 +0100 Subject: [PATCH 167/247] [Build] Use versionless Qt CMake commands (#7639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Build] Use versionless Qt CMake commands Replace the versioned qt6_add_executable, qt6_add_resources, qt6_add_shaders,\nqt6_add_translations and qt6_wrap_cpp calls with their unversioned forms.\nAll five are documented as available at the project's 6.4 minimum and accept\nthe same arguments.\n\nVerification: full client, Oracle, server and test build passed. * versionless qt_finalize_target and _project --------- Co-authored-by: Lukas Brübach Co-authored-by: tooomm --- CMakeLists.txt | 2 +- cockatrice/CMakeLists.txt | 14 +++++++------- oracle/CMakeLists.txt | 10 +++++----- servatrice/CMakeLists.txt | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4cdc19911..d9acabc5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -462,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/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a4dc96937..ce7ad5712 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -490,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 @@ -511,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 @@ -523,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 @@ -535,7 +535,7 @@ qt6_add_shaders( src/interface/widgets/onboarding/shaders/brand_plate.frag ) -qt6_add_resources( +qt_add_resources( cockatrice "onboarding_qml" PREFIX @@ -768,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} @@ -778,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) @@ -804,4 +804,4 @@ if(Qt6LinguistTools_FOUND) endforeach() endif() -qt6_finalize_target(cockatrice) +qt_finalize_target(cockatrice) 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/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) From 825aba953a52c4a325a971e8aa52e62e6f17efd0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:02:49 +0100 Subject: [PATCH 168/247] [Servatrice] Fail loudly when startup initialization fails (#7602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Exit non-zero when server initialization fails initServer() reported every startup failure through a zero exit code: the database could not be opened, the server printed nothing of its own, and the process looked like a clean shutdown. Log the two refusal reasons at critical level and return 1 so supervisors and the Docker exit status can tell the difference. * [Servatrice] Keep console logging independent of the logfile logMessage() returned before the console echo unless a logfile was open and server/writelog was true, so --log-to-console produced nothing when server/logfile was empty or could not be opened. That is how a refused database connection could end in a silent exit. Print to the console before the logfile gates, drop the duplicate echo from flushBuffer(), and read writelog/logfilters once in main instead of calling the non-thread-safe global QSettings from every logging thread. * [Servatrice] Uninstall the log handler before tearing down globals The handler reaches into the global logger and settingsCache. Leaving it installed while main deletes those objects turns any Qt warning emitted during QCoreApplication/QFactoryLoader teardown into a use-after-free; with QT_DEBUG_PLUGINS=1 that warning exists, and Servatrice died with SIGSEGV in QSettings::value() on the way out. * [Servatrice] Buffer console logs on the logger thread Address review feedback on the startup failure change: - Append console lines to a buffer drained by the logger thread, so a stalled stdout reader cannot block game threads. - Report islServer->errorString() instead of the literal call text. - Let the constructor reuse reloadConfig() instead of parsing the filter list twice. --------- Co-authored-by: Lukas Brübach --- servatrice/src/main.cpp | 14 ++- servatrice/src/servatrice.cpp | 14 +-- servatrice/src/server_logger.cpp | 140 +++++++++++++++--------- servatrice/src/server_logger.h | 13 ++- servatrice/src/signalhandler.cpp | 2 + tests/CMakeLists.txt | 11 ++ tests/server_logger_test.cpp | 179 +++++++++++++++++++++++++++++++ 7 files changed, 311 insertions(+), 62 deletions(-) create mode 100644 tests/server_logger_test.cpp 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..dfe51183a 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -1,7 +1,5 @@ #include "server_logger.h" -#include "settingscache.h" - #include #include #include @@ -10,33 +8,37 @@ #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,14 +46,22 @@ 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 @@ -61,43 +71,54 @@ bool ServerLogger::isLoggingEnabled() const 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 +128,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 +179,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..531430d56 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. + 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/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 c3b9a7547..c8d3fbd59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -251,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 diff --git a/tests/server_logger_test.cpp b/tests/server_logger_test.cpp new file mode 100644 index 000000000..94a6909bf --- /dev/null +++ b/tests/server_logger_test.cpp @@ -0,0 +1,179 @@ +#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, 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 From 344dbd2caaf8eba61c8cbb7cd8534a68cc9f26d1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:02:55 +0100 Subject: [PATCH 169/247] [Servatrice] Fix silently broken socket error connections (#7626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Fix silently broken socket error connections QAbstractSocket::error() was removed in Qt 6 in favour of errorOccurred(). The ISL interface connected to the old name through SIGNAL(), which compiles but fails at runtime, so catchSocketError never ran. The WebSocket interface used the same name on QWebSocket, where it still exists but is deprecated since 6.5. Use errorOccurred for both. The WebSocket connect needs a lambda because catchSocketError is a protected base-class slot and a base pointer-to-member is not accessible from the derived class. * [Servatrice] Guard the WebSocket error connect against Qt 6.4 QWebSocket gained errorOccurred in 6.5 and dropped the error signal with it, so the typed connect cannot compile against the 6.4 floor that Debian 12 and Ubuntu 24.04 still use. Keep the typed connect on 6.5+ and fall back to the old-style connect below it, which is what the code did before. Verified both branches compile: forcing the guard to the else path and rebuilding serversocketinterface.cpp succeeds, so the 6.4 spelling is intact. * [Servatrice] Initialize ISL serverId before authentication catchSocketError() removes islInterfaces[serverId], but the incoming connection constructor left serverId uninitialized until initServer() set it after the SSL handshake and certificate check. A socket error during waitForEncrypted() therefore read an indeterminate key. * [Servatrice] Correct the QWebSocket error signal note error() was deprecated in 6.5, not removed; errorOccurred() was added in 6.5. --------- Co-authored-by: Lukas Brübach --- servatrice/src/isl_interface.cpp | 5 ++--- servatrice/src/serversocketinterface.cpp | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) 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/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. From 00151380e3bedb475e532a69d0b4490a5b33f438 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:37:15 +0100 Subject: [PATCH 170/247] [CardDB] Break the CardInfo <-> CardSet cycle on reload (#7645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Drop the CardInfo self pointer The weak self pointer had to be assigned right after every construction, and a missed assignment silently stopped change notifications. CardInfo is always owned by a QSharedPointer, so it now derives from QEnableSharedFromThis and asks for sharedFromThis() instead: nothing to initialize, and the CardSet cycle stays broken. CardSet::newInstance() also loses the commented-out self-pointer assignment left over from the same design. * [CardDB] Pin the cardInfoChanged contract without the self pointer Dropping CardInfo's self pointer moved change notifications onto QEnableSharedFromThis, so the signal now rests on the card being owned by a QSharedPointer. Two tests pin that down: - A property change on a card owned by a CardDatabase still emits cardInfoChanged() carrying that card, which is what the card database views and models listen to. - clone() starts out unowned, so a change to the clone is reported as the clone's and must not reach the original's observers. * [CardDB] Treat an unowned CardInfo as a bug, not a no-op Every CardInfo is built inside a QSharedPointer (both factory overloads and clone()), and no constructor notifies anyone, so sharedFromThis() is always available by the time addToSet() or a setter runs. Assert that and warn in a released build, instead of silently skipping the notification or the set append. The copy constructor keeps its explicit QEnableSharedFromThis initializer: GCC's -Wextra warns about the uninitialized base in every translation unit that copies a CardInfo otherwise. A comment there records that the clone deliberately starts unowned. * [CardDB] Break the CardInfo <-> CardSet cycle on reload CardSet is a QList and CardInfo::addToSet() appends the card to its sets, while a card keeps a CardSetPtr per printing in setsToPrintings. The sets therefore hold strong references back to the cards, and the previous database snapshot stayed alive after a reload, so RSS grew by roughly a second database on every reload. Empty the sets before the cards are dropped: CardDatabase::clear() and swapInDatabaseData() clear the sets before replacing the containers, and removeCard() detaches the card from its sets, so nothing keeps a card alive on behalf of a set once the database lets go of it. Two tests pin the behaviour: a reload and a clear() must drop the last strong reference to a card from the previous snapshot. * [CardDB] Keep the set detach in one place clear() and swapInDatabaseData() both emptied every set before dropping the cards, so share that as detachSetMembership() instead of two copies that have to stay in sync. removeCard() now detaches through the first printing of each set directly, the same way CardInfo::newInstance() appends, instead of an inner loop that always breaks after the first item. Because clear() empties the sets up front, its per-card detach was walking every card's printings to remove it from sets that were already empty. Split removeCard() into the per-card detach and unindexCard(), and let clear() call only the latter. * [CardDB] Test that removeCard detaches the card from its set removeCard() is the one teardown path a caller can trigger for a single card (the token editor uses it), and it now detaches the card from its sets as well. Without that detach the set's strong CardInfoPtr keeps the removed card alive forever, which is exactly what the reload and clear tests pin for the whole-snapshot paths. The test loads the fixture, removes Cat, and requires the card to be gone from the CAT set and to be destroyed once the test drops its own reference. * [Servatrice] Drop the settings cache from the logger #7602 removed #include "settingscache.h" from server_logger.cpp but left isLoggingEnabled() reading settingsCache, so master no longer compiles: the servatrice target fails on the undeclared global and server_logger_test, which compiles the same file without main.cpp, fails to link it. The same commit made reloadConfig() the single place that stores writelog and the log filters, precisely so logging threads stop reading the global QSettings. isLoggingEnabled() now mirrors logMessage() and reads those members instead: the console is a sink of its own, and the logfile needs an open file plus writelog. The mutex is mutable so the const query can take it. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/card/card_info.cpp | 30 ++++- .../libcockatrice/card/card_info.h | 51 ++++----- .../card/database/card_database.cpp | 36 +++++- .../card/database/card_database.h | 17 +++ .../libcockatrice/card/set/card_set.cpp | 1 - servatrice/src/server_logger.cpp | 5 +- servatrice/src/server_logger.h | 2 +- tests/carddatabase/carddatabase_test.cpp | 106 ++++++++++++++++++ tests/server_logger_test.cpp | 22 ++++ 9 files changed, 232 insertions(+), 38 deletions(-) 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/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index dfe51183a..09453b7ee 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -66,7 +67,9 @@ void ServerLogger::reloadConfig(bool _writeLog, const QString &_logFilters) 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) diff --git a/servatrice/src/server_logger.h b/servatrice/src/server_logger.h index 531430d56..40df9fd62 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -41,7 +41,7 @@ private: bool messageFilterActive = false; QStringList logFilters; // Guards writeLog/messageFilterActive/logFilters: SIGHUP rewrites them while other threads log. - QMutex configMutex; + mutable QMutex configMutex; QFile *logFile = nullptr; bool flushRunning; QStringList buffer; 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/server_logger_test.cpp b/tests/server_logger_test.cpp index 94a6909bf..3c2d047cc 100644 --- a/tests/server_logger_test.cpp +++ b/tests/server_logger_test.cpp @@ -98,6 +98,28 @@ TEST(ServerLoggerTest, StaysQuietWhenConsoleIsDisabled) 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; From ff393b899041fde2ad08f4efc1f99e07a855e823 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:49:35 +0100 Subject: [PATCH 171/247] [Game] Connect zone dump response before sending the request (#7711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZoneViewZone::initializeCards() connected PendingCommand::finished only after ZoneViewZoneLogic::requestZoneDump() had already sent the command. Local games process the dump synchronously, so finished was emitted before the view connected and the library/sideboard view stayed empty. Split requestZoneDump() into prepareZoneDump() and sendZoneDump(), and connect before sending, matching the TabRoom prepare/send convention. Co-authored-by: Lukas Brübach --- cockatrice/src/game_graphics/zones/view_zone.cpp | 6 +++++- .../libcockatrice/game/zones/view_zone_logic.cpp | 16 +++++++++------- .../libcockatrice/game/zones/view_zone_logic.h | 15 +++++++++++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 83c13d467..be2e471d4 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -99,9 +99,13 @@ void ZoneViewZone::initializeCards(const QList &cardLis } reorganizeCards(); } else if (!qobject_cast(getLogic())->getOriginalZone()->contentsKnown()) { - PendingCommand *pend = qobject_cast(getLogic())->requestZoneDump(); + 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(); diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index 88941f12b..db1fa32c3 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -192,12 +192,14 @@ void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) writeableRevealZone = _writeableRevealZone; } -PendingCommand *ZoneViewZoneLogic::requestZoneDump() +PendingCommand *ZoneViewZoneLogic::prepareZoneDump() { - PlayerActions *actions = getPlayer()->getPlayerActions(); - PendingCommand *pend = actions->prepareZoneDump(this, numberCards); - if (pend != nullptr) { - actions->sendGameCommand(pend); - } - return pend; + return getPlayer()->getPlayerActions()->prepareZoneDump(this, numberCards); +} + +void ZoneViewZoneLogic::sendZoneDump(PendingCommand *pendingCommand) +{ + if (pendingCommand != nullptr) { + getPlayer()->getPlayerActions()->sendGameCommand(pendingCommand); + } } diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index 3592db780..a29e543a3 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -88,14 +88,21 @@ public: } /** - * @brief Prepares and sends the zone-dump command for this view. + * @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 command is sent - * asynchronously, so callers connect to finished() after calling this. + * 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 *requestZoneDump(); + PendingCommand *prepareZoneDump(); + + /** + * @brief Sends a command returned by prepareZoneDump(). + * @param pendingCommand The prepared command; nullptr is ignored. + */ + void sendZoneDump(PendingCommand *pendingCommand); protected: void addCardImpl(CardState *card, int x, int y) override; From e08402ac941d932da3fa8368ee88213a461a6123 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 1 Nov 2026 16:22:51 +0100 Subject: [PATCH 172/247] bump vcpkg submodule (#7690) --- vcpkg | 2 +- vcpkg.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vcpkg b/vcpkg index 9e593bb18..9eec002df 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d +Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 diff --git a/vcpkg.json b/vcpkg.json index f12227024..8c9c64161 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", - "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d", + "builtin-baseline": "9eec002df1cd109f8f104cd966a763daf9751489", "dependencies": [ "gtest", "liblzma", From ef4574d2b9494d18b99012d34590c36ca655531a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:00:48 +0100 Subject: [PATCH 173/247] [Server] Store a scrypt verifier when an admin resets a password (#7679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temporary password was written as a legacy salt+hash row, which makes the next login look like an authentication downgrade. A client holding a saved verifier refuses that downgrade, so the reset locked the account out of every modern client. Store the verifier and, where security/legacy_backup_key is configured, keep a sealed legacy hash of the same temporary password as the downgrade backup, so pre-challenge clients keep the credential path the reset used to provide. Without the key no row shape serves both: the verifier and the legacy hash cannot share one column, so those clients have no path at all. Warn at reset time that the new password has no pre-challenge path, and say so in servatrice.ini.example instead of promising that a reset gets them back in. Co-authored-by: Lukas Brübach --- servatrice/servatrice.ini.example | 8 +- .../src/servatrice_database_interface.cpp | 81 ++++++++++++++++--- .../src/servatrice_database_interface.h | 8 ++ servatrice/src/serversocketinterface.cpp | 10 ++- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 021f5157f..ac291fd7f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -455,9 +455,11 @@ authentication_strictness=mixed ; the scrypt verifier, so old clients can still log in during the window below without ; the database holding a fast-crackable hash in the clear. ; Generate with: openssl rand -hex 32 -; If unset, migrated accounts have no downgrade window: old clients must use a password -; reset to get back in. Rotating the key invalidates existing backups (they become -; unusable immediately; expired ones are still purged). +; If unset there is no downgrade path at all: a migrated account keeps only a scrypt +; verifier, and an administrator's password reset stores a verifier too, so pre-challenge +; clients cannot log in with the temporary password either. Set the key if you still serve +; them. Rotating the key invalidates existing backups (they become unusable immediately; +; expired ones are still purged). legacy_backup_key= ; Days a legacy password backup may go unused before it is purged from the database. diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index b5c45065c..1b49c3a82 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -755,6 +755,20 @@ QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user) return query->value(0).toString(); } +QString Servatrice_DatabaseInterface::sealLegacyBackup(const QString &storedName, const QString &credential) +{ + const QByteArray key = server->getLegacyBackupKey(); + if (key.isEmpty()) { + return {}; + } + const QByteArray aad = storedName.simplified().toUtf8(); + const QString sealed = CryptoUtil::encryptSecret(credential.toUtf8(), key, aad); + if (sealed.isEmpty() || sealed.length() > MAX_LEGACY_BACKUP_LENGTH) { + return {}; + } + return sealed; +} + bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier) { if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { @@ -779,18 +793,13 @@ bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, c QString sealedBackup; QDateTime backupUsedAt; - if (PasswordHasher::isLegacyFormat(currentCredential)) { - const QByteArray key = server->getLegacyBackupKey(); - if (!key.isEmpty()) { - const QByteArray aad = storedName.simplified().toUtf8(); - sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad); - if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) { - qCWarning(DatabaseInterfaceLog) - << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; - sealedBackup.clear(); - } else { - backupUsedAt = QDateTime::currentDateTimeUtc(); - } + if (PasswordHasher::isLegacyFormat(currentCredential) && !server->getLegacyBackupKey().isEmpty()) { + sealedBackup = sealLegacyBackup(storedName, currentCredential); + if (sealedBackup.isEmpty()) { + qCWarning(DatabaseInterfaceLog) + << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; + } else { + backupUsedAt = QDateTime::currentDateTimeUtc(); } } @@ -1644,6 +1653,54 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, return passwordQuery->numRowsAffected() > 0; } +bool Servatrice_DatabaseInterface::changeUserPasswordWithLegacyBackup(const QString &user, + const QString &passwordVerifier, + const QString &legacyCredential) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return false; + } + + if (!checkSql()) { + return false; + } + + // Seal against the stored row name, not the caller-supplied one: the name column is + // case-insensitive, so a differently-cased name would fail the tag check on open. + QSqlQuery *nameQuery = prepareQuery("SELECT name FROM {prefix}_users WHERE name = :name"); + nameQuery->bindValue(":name", user); + if (!execSqlQuery(nameQuery) || !nameQuery->next()) { + return false; + } + const QString storedName = nameQuery->value(0).toString(); + + const QString sealedBackup = sealLegacyBackup(storedName, legacyCredential); + const QDateTime backupUsedAt = sealedBackup.isEmpty() ? QDateTime() : QDateTime::currentDateTimeUtc(); + if (sealedBackup.isEmpty()) { + // Without a backup a pre-challenge client has nothing to authenticate against: the + // row holds a verifier and the legacy hash it would send cannot match it. Say so, + // because the symptom - the temporary password appearing not to work - shows up on + // a different machine from the one that reset it. + qCWarning(DatabaseInterfaceLog) << "Stored a verifier without a legacy backup for user" << user + << "- pre-challenge clients cannot use this password" + << "- check security/legacy_backup_key"; + } + + QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512=:password, " + "password_legacy_backup=:backup, password_legacy_backup_used_at=:used_at, " + "passwordLastChangedDate = NOW() where name = :name"); + query->bindValue(":password", passwordVerifier); + query->bindValue(":backup", sealedBackup); + query->bindValue(":used_at", backupUsedAt.isValid() + ? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))) + : QVariant()); + query->bindValue(":name", storedName); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force) { if (!checkSql()) { diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 6b38384e4..a27bd55b8 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -62,6 +62,9 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining); + /** Seal a legacy credential for the account's downgrade backup; empty when the + * backup key is unset or the sealed value does not fit the column. */ + QString sealLegacyBackup(const QString &storedName, const QString &credential); bool isStrictModeEnabled(bool &ok) const; protected: @@ -192,6 +195,11 @@ public: const QString &targetName, const QString &channel = QString()) override; bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override; + /** Replace a credential with an already-derived verifier, keeping a sealed legacy hash of the + * same password as the downgrade backup for pre-challenge clients. */ + bool changeUserPasswordWithLegacyBackup(const QString &user, + const QString &passwordVerifier, + const QString &legacyCredential); bool changeUserPassword(const QString &user, const QString &oldPassword, bool oldPasswordNeedsHash, diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 78593f6dc..963b98c9c 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2641,7 +2641,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const } const QString tempPassword = PasswordHasher::generateRandomSalt(); - if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + // Store a scrypt verifier rather than a legacy salt+hash: a legacy row makes the next + // login look like an authentication downgrade, which modern clients refuse when they + // still hold a verifier for the account. Where security/legacy_backup_key is configured, + // keep a sealed legacy hash of the same temporary password as the downgrade backup so + // pre-challenge clients keep the path the reset used to provide; without the key they + // have none, which changeUserPasswordWithLegacyBackup warns about. + const QString verifier = PasswordHasher::generatePasswordVerifier(tempPassword); + const QString legacyCredential = PasswordHasher::computeHash(tempPassword, PasswordHasher::generateRandomSalt()); + if (!sqlInterface->changeUserPasswordWithLegacyBackup(userName, verifier, legacyCredential)) { return Response::RespInternalError; } sqlInterface->setForcePasswordChange(userName, true); From 50a2ae9cc8d56f2014479e88dcb0b25dfea6bb7d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:01:29 +0100 Subject: [PATCH 174/247] [Server/Client] Enforce and complete the forced password change at login (#7680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkUserPassword ignored force_password_change on the challenge-response path, so a reset password stayed valid. Returning PasswordChangeRequired was not enough on its own: the session was dropped and the client told the user to ask the administrator for the temporary password they had just used. The pending session now keeps its identity without registering a user session or announcing a join, may only ping and change its password, and the client completes the change in-session before reconnecting. Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 56 ++++- .../remote_connection_controller.h | 4 + .../widgets/server/user/user_info_box.cpp | 13 +- .../network/client/abstract/CMakeLists.txt | 2 +- .../client/abstract/abstract_client.cpp | 21 ++ .../network/client/abstract/abstract_client.h | 14 ++ .../network/client/remote/remote_client.cpp | 41 +++ .../network/client/remote/remote_client.h | 11 + .../network/server/remote/server.cpp | 28 ++- .../server/remote/server_protocolhandler.cpp | 28 ++- .../server/remote/server_protocolhandler.h | 6 + .../src/servatrice_database_interface.cpp | 4 + servatrice/src/serversocketinterface.cpp | 3 + tests/CMakeLists.txt | 22 ++ tests/client_login_status_test.cpp | 116 +++++++++ tests/server_forced_password_change_test.cpp | 234 ++++++++++++++++++ 16 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 tests/client_login_status_test.cpp create mode 100644 tests/server_forced_password_change_test.cpp 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 020e53877..c702f91f1 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -3,6 +3,7 @@ #include "../../../interface/pixel_map_generator.h" #include "../../settings/cache_settings.h" #include "../interface/widgets/dialogs/dlg_connect.h" +#include "../interface/widgets/dialogs/dlg_edit_password.h" #include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h" #include "../interface/widgets/dialogs/dlg_forgot_password_request.h" #include "../interface/widgets/dialogs/dlg_forgot_password_reset.h" @@ -99,6 +100,10 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this, &ConnectionController::onPasswordVerifierReady); + + connect(remoteClient, &RemoteClient::sigPasswordChangeSuccess, this, + &ConnectionController::onPasswordChangeSuccess); + connect(remoteClient, &RemoteClient::sigPasswordChangeError, this, &ConnectionController::onPasswordChangeError); } void ConnectionController::connectToServer() @@ -351,10 +356,16 @@ void ConnectionController::onLoginError(int r, } case Response::RespPasswordChangeRequired: { - QMessageBox::information( - dialogParent, tr("Password Change Required"), - tr("An administrator has reset your password. Please contact your server administrator to obtain " - "your temporary password, then log in and change it via Account -> Change Password.")); + // The credentials were right, but the account may not be used until a new password + // is chosen. Let the user complete the change here: sending them off to "ask the + // administrator for your temporary password" sends them in circles, since they just + // logged in with it and the session ends without a way to comply. + DlgEditPassword dlg(dialogParent); + dlg.setWindowTitle(tr("Password Change Required")); + if (dlg.exec()) { + remoteClient->changePasswordToServer(dlg.getOldPassword(), dlg.getNewPassword()); + return; // onPasswordChangeSuccess/onPasswordChangeError decides the next step + } remoteClient->disconnectFromServer(); return; } @@ -517,6 +528,43 @@ void ConnectionController::onNotifyUserAboutUpdate() "To update your client, go to Help -> Check for Updates.")); } +void ConnectionController::onPasswordChangeSuccess() +{ + // The credential saved for this profile is stale now: drop it so the next login asks for + // the new password instead of replaying the old verifier. + if (!pendingSaveName.isEmpty()) { + SettingsCache::instance().servers().setServerPassword(pendingSaveName, ""); + } + + QMessageBox::information(dialogParent, tr("Password Changed"), + tr("Your password has been changed. Please log in again with your new password.")); + remoteClient->disconnectFromServer(); + connectToServer(); +} + +void ConnectionController::onPasswordChangeError(int responseCode) +{ + QString reason; + switch (static_cast(responseCode)) { + case Response::RespWrongPassword: + reason = tr("The current password is incorrect."); + break; + case Response::RespPasswordTooShort: + reason = tr("The new password is too short."); + break; + case Response::RespClientUpdateRequired: + reason = tr("The server does not accept the new password format."); + break; + default: + reason = tr("The password could not be changed (error %1).").arg(responseCode); + break; + } + + QMessageBox::critical(dialogParent, tr("Password Change Failed"), reason); + remoteClient->disconnectFromServer(); + connectToServer(); +} + void ConnectionController::onForgotPasswordSuccess() { QMessageBox::information( 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 c8f96a9a9..2987a8d99 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -94,6 +94,10 @@ private slots: // Persists the derived scrypt verifier after a successful challenge-response login void onPasswordVerifierReady(const QString &verifier); + // Completes the forced password change the server demanded at login + void onPasswordChangeSuccess(); + void onPasswordChangeError(int responseCode); + private: void wireClientSignals(); void updateWindowTitle(); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 4feb73538..fa7698037 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -299,17 +298,7 @@ void UserInfoBox::actPassword() void UserInfoBox::changePassword(const QString &oldPassword, const QString &newPassword) { - Command_AccountPassword cmd; - cmd.set_old_password(oldPassword.toStdString()); - if (client->getServerSupportsChallengeResponse()) { - cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); - } else if (client->getServerSupportsPasswordHash()) { - auto passwordSalt = PasswordHasher::generateRandomSalt(); - QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt); - cmd.set_hashed_new_password(hashedPassword.toStdString()); - } else { - cmd.set_new_password(newPassword.toStdString()); - } + const Command_AccountPassword cmd = client->makeAccountPasswordCommand(oldPassword, newPassword); PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &UserInfoBox::processPasswordResponse); diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 880cef5a1..791db6348 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -15,7 +15,7 @@ target_include_directories(libcockatrice_network_client_abstract PUBLIC .) target_link_libraries( libcockatrice_network_client_abstract PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol - libcockatrice_network_server_remote + libcockatrice_network_server_remote libcockatrice_utility ) target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index 0c523d776..04e6beda0 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -10,6 +10,7 @@ #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/session_commands.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" +#include "libcockatrice/utility/passwordhasher.h" #include #include @@ -80,6 +81,26 @@ AbstractClient::~AbstractClient() { } +Command_AccountPassword AbstractClient::makeAccountPasswordCommand(const QString &oldPassword, + const QString &newPassword) const +{ + Command_AccountPassword cmd; + cmd.set_old_password(oldPassword.toStdString()); + if (getServerSupportsChallengeResponse()) { + // A verifier lets the server upgrade the stored credential without ever seeing the + // password, and it is what a challenge-response server expects for a new password. + cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); + } else if (getServerSupportsPasswordHash()) { + // The server hashes the password itself with its own salt; a salted hash is the + // strongest form it accepts, so the plaintext never goes on the wire. + const QString passwordSalt = PasswordHasher::generateRandomSalt(); + cmd.set_hashed_new_password(PasswordHasher::computeHash(newPassword, passwordSalt).toStdString()); + } else { + cmd.set_new_password(newPassword.toStdString()); + } + return cmd; +} + void AbstractClient::processProtocolItem(const ServerMessage &item) { switch (item.message_type()) { diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 7efd67972..005587ccc 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -47,6 +47,7 @@ class Event_UserJoined; class Event_UserLeft; class Event_ServerMessage; class Event_ListRooms; +class Command_AccountPassword; class Event_GameJoined; class Event_UserMessage; class Event_NotifyUser; @@ -67,6 +68,9 @@ enum ClientStatus StatusSubmitForgotPasswordReset, StatusSubmitForgotPasswordChallenge, StatusGettingPasswordSalt, + //! The server accepted the credentials but keeps the session open until the account + //! password is replaced; the connection must survive for `Command_AccountPassword`. + StatusPasswordChangeRequired, }; class AbstractClient : public QObject @@ -171,6 +175,16 @@ public: { return serverSupportsChallengeResponse; } + + /** + * @brief Builds the account-password command in the form this server understands. + * + * Challenge-response servers take a scrypt verifier; servers that only advertise + * SupportsPasswordHash take a salted SHA-512 hash; anything older gets the plaintext, + * which is the only form it understands. Both password-change paths go through here so + * the two cannot drift apart and hand a plaintext password to a server that can hash it. + */ + Command_AccountPassword makeAccountPasswordCommand(const QString &oldPassword, const QString &newPassword) const; const QString &getUserName() const { return userName; diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 7eab58668..faacdc9f7 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -125,6 +125,7 @@ RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSe &RemoteClient::doSubmitForgotPasswordResetToServer); connect(this, &RemoteClient::sigSubmitForgotPasswordChallengeToServer, this, &RemoteClient::doSubmitForgotPasswordChallengeToServer); + connect(this, &RemoteClient::sigChangePasswordToServer, this, &RemoteClient::doChangePasswordToServer); } RemoteClient::~RemoteClient() @@ -354,6 +355,29 @@ void RemoteClient::submitPasswordVerifierResponse(const Response &response) } } +void RemoteClient::changePasswordToServer(const QString &oldPassword, const QString &newPassword) +{ + emit sigChangePasswordToServer(oldPassword, newPassword); +} + +void RemoteClient::doChangePasswordToServer(const QString &oldPassword, const QString &newPassword) +{ + const Command_AccountPassword cmd = makeAccountPasswordCommand(oldPassword, newPassword); + + PendingCommand *pend = prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &RemoteClient::accountPasswordResponse); + sendCommand(pend); +} + +void RemoteClient::accountPasswordResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + emit sigPasswordChangeSuccess(); + } else { + emit sigPasswordChangeError(response.response_code()); + } +} + void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/) { doDisconnectFromServer(); @@ -475,6 +499,23 @@ void RemoteClient::loginResponse(const Response &response) emit notifyUserAboutUpdate(); } + } else if (response.response_code() == Response::RespPasswordChangeRequired) { + // The credentials were accepted: the server keeps this session open and serves + // nothing but ping and Command_AccountPassword until the password is replaced. + // Treating it as a failed login would disconnect before the dialog that collects + // the new password (queued to the UI thread behind this slot) can send anything: + // the command would sit in pendingCommands forever, because the ping timer that + // times it out stops with the connection. Keep the session and let the controller + // report the outcome through accountPasswordResponse(). + QList missingFeatures; + if (resp.missing_features_size() > 0) { + for (int i = 0; i < resp.missing_features_size(); ++i) { + missingFeatures << QString::fromStdString(resp.missing_features(i)); + } + } + setStatus(StatusPasswordChangeRequired); + emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()), + static_cast(resp.denied_end_time()), missingFeatures); } else if (response.response_code() != Response::RespNotConnected) { QList missingFeatures; if (resp.missing_features_size() > 0) { diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index d0c0a18ce..1cf40491c 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -71,6 +71,11 @@ signals: //! should only store it for the connection it is currently negotiating, so //! the hostname and user name are intentionally not part of the signal. void sigPasswordVerifierReady(const QString &verifier); + //! \brief Outcome of the password change the server demanded at login. + void sigPasswordChangeSuccess(); + void sigPasswordChangeError(int responseCode); + //! \brief Request a password change; queued to the client thread like the other requests. + void sigChangePasswordToServer(const QString &oldPassword, const QString &newPassword); private slots: void slotConnected(); void readData(); @@ -99,6 +104,8 @@ private slots: Command_Login generateCommandLogin(); void doSubmitPasswordVerifier(); void submitPasswordVerifierResponse(const Response &response); + void accountPasswordResponse(const Response &response); + void doChangePasswordToServer(const QString &oldPassword, const QString &newPassword); void doDisconnectFromServer(); void doActivateToServer(const QString &_token); void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); @@ -201,6 +208,10 @@ public: unsigned int port, const QString &_userName, const QString &_email); + //! \brief Change the password of the account that just logged in, following the + //! server's forced-password-change reply. Emits sigPasswordChangeSuccess or + //! sigPasswordChangeError when the server answers. + void changePasswordToServer(const QString &oldPassword, const QString &newPassword); }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 63989bbfa..8ea2cd993 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -131,6 +131,16 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session, return authState; } + if (authState == PasswordChangeRequired) { + // The credentials were right, but the account must choose a new password before it may + // use the server. Give the session an identity so it can complete that change, without + // registering a user session or announcing the user as logged in. + ServerInfo_User pendingData = databaseInterface->getUserData(name, true); + pendingData.set_address(session->getAddress().toStdString()); + session->setUserInfo(pendingData); + return authState; + } + ServerInfo_User data = databaseInterface->getUserData(name, true); data.set_address(session->getAddress().toStdString()); name = QString::fromStdString(data.name()); // Compensate for case indifference @@ -296,7 +306,9 @@ void Server::removeClient(Server_ProtocolHandler *client) QWriteLocker locker(&clientsLock); clients.removeAt(clientIndex); ServerInfo_User *data = client->getUserInfo(); - if (data) { + // A session that never completed login (for example one that still owes a password change) + // has no session id: it was never announced as joined, so it must not be announced as leaving. + if (data && data->has_session_id()) { Event_UserLeft event; event.set_name(data->name()); SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event); @@ -311,12 +323,10 @@ void Server::removeClient(Server_ProtocolHandler *client) users.remove(QString::fromStdString(data->name())); qDebug() << "Server::removeClient: name=" << QString::fromStdString(data->name()); - if (data->has_session_id()) { - const qint64 sessionId = data->session_id(); - usersBySessionId.remove(sessionId); - emit endSession(sessionId); - qDebug() << "closed session id:" << sessionId; - } + const qint64 sessionId = data->session_id(); + usersBySessionId.remove(sessionId); + emit endSession(sessionId); + qDebug() << "closed session id:" << sessionId; } qDebug() << "Server::removeClient: removed" << (void *)client << ";" << clients.size() << "clients; " << users.size() << "users left"; @@ -330,7 +340,9 @@ QList Server::getOnlineModeratorList() const ServerInfo_User *data = client->getUserInfo(); //! \todo This line should be updated in the event there is any type of new user level created. - if (data && + // A session that still owes a forced password change has user info but no session id: + // it never joined, so it must not be reported as an online moderator either. + if (data && data->has_session_id() && (data->user_level() & ServerInfo_User::IsModerator || data->user_level() & ServerInfo_User::IsAdmin)) { results << QString::fromStdString(data->name()).simplified(); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index dd074a517..39c1eb3e6 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -64,8 +64,9 @@ Server_ProtocolHandler::Server_ProtocolHandler(Server *_server, Server_DatabaseInterface *_databaseInterface, QObject *parent) : QObject(parent), Server_AbstractUserInterface(_server), deleted(false), databaseInterface(_databaseInterface), - authState(NotLoggedIn), usingRealPassword(false), acceptsUserListChanges(false), acceptsRoomListChanges(false), - idleClientWarningSent(false), timeRunning(0), lastDataReceived(0), lastActionReceived(0) + authState(NotLoggedIn), usingRealPassword(false), passwordChangePending(false), acceptsUserListChanges(false), + acceptsRoomListChanges(false), idleClientWarningSent(false), timeRunning(0), lastDataReceived(0), + lastActionReceived(0) { connect(server, &Server::pingClockTimeout, this, &Server_ProtocolHandler::pingClockTimeout); @@ -212,7 +213,10 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co logDebugMessage(getSafeDebugString(sc)); } const auto commandType = static_cast(num); - if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { + if (passwordChangePending && commandType != SessionCommand::PING && + commandType != SessionCommand::ACCOUNT_PASSWORD) { + resp = Response::RespFunctionNotAllowed; + } else if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { resp = Response::RespLoginNeeded; } else { switch (commandType) { @@ -489,6 +493,19 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con lastDataReceived = timeRunning; + // A session that still owes a forced password change may only reach the session family, + // where processSessionCommandContainer narrows it further to ping and the change itself. + // Every other family is rejected here, including a container that also carries a session + // command: the dispatch below prefers game/room over session, so testing only for the + // absence of a session command would let such a container through. + if (passwordChangePending && + (cont.game_command_size() || cont.room_command_size() || cont.moderator_command_size() || + cont.admin_command_size() || cont.developer_command_size())) { + sendResponseContainer(ResponseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1), + Response::RespFunctionNotAllowed); + return; + } + ResponseContainer responseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1); Response::ResponseCode finalResponseCode; @@ -696,6 +713,11 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd server->recordFailedLogin(getAddress(), userName); return Response::RespAccountNotActivated; case PasswordChangeRequired: + // The credentials were right, but the account must choose a new password first. + // Keep the session authenticated just far enough for the client to do that: + // processCommandContainer refuses everything except the change itself. + authState = PasswordRight; + passwordChangePending = true; return Response::RespPasswordChangeRequired; default: server->clearFailedLogins(getAddress(), userName); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 266c4b26c..8ce110354 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -46,6 +46,12 @@ protected: Server_DatabaseInterface *databaseInterface; AuthenticationResult authState; bool usingRealPassword; + /** + * @brief True when the credentials were right but the account must choose a new password + * before it may use the server. Only ping, the password change and disconnecting + * are served while this is set. + */ + bool passwordChangePending; bool acceptsUserListChanges; bool acceptsRoomListChanges; bool idleClientWarningSent; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 1b49c3a82..45ae9bde8 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -482,6 +482,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot const QByteArray expected = PasswordHasher::computeResponse(key, nonce); handler->clearAuthNonce(); if (PasswordHasher::constantTimeEquals(expected, response)) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right"; return PasswordRight; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 963b98c9c..f0c0c3536 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -3787,6 +3787,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C } databaseInterface->setForcePasswordChange(userName, false); + // The forced change is done, but this session never completed login (see + // Server_ProtocolHandler::cmdLogin). It stays restricted to ping and another password + // change; the client reconnects with the new password to actually enter the server. return Response::RespOk; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c8d3fbd59..00b39193d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -328,6 +328,28 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_forced_password_change_test + SOURCES + server_forced_password_change_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) +cockatrice_add_test( + NAME + client_login_status_test + SOURCES + ${VERSION_STRING_CPP} + client_login_status_test.cpp + LIBS + libcockatrice_network_client_remote + LABELS + ci +) cockatrice_add_test( NAME server_room_channels_test diff --git a/tests/client_login_status_test.cpp b/tests/client_login_status_test.cpp new file mode 100644 index 000000000..d8ea63550 --- /dev/null +++ b/tests/client_login_status_test.cpp @@ -0,0 +1,116 @@ +/** @file client_login_status_test.cpp + * @brief The login response state machine must keep the session a forced password change needs. + * @ingroup Tests + */ + +#include "abstract_client.h" +#include "libcockatrice/interfaces/interface_network_settings_provider.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_login.pb.h" +#include "remote_client.h" + +#include +#include +#include +#include + +namespace +{ + +class StubNetworkSettingsProvider : public INetworkSettingsProvider +{ +public: + QString getClientID() override + { + return {}; + } + [[nodiscard]] int getTimeOut() const override + { + return 1000; + } + [[nodiscard]] int getKeepAlive() const override + { + return 60; + } + [[nodiscard]] bool getNotifyAboutUpdates() const override + { + return false; + } + void setKnownMissingFeatures(const QString &) override + { + } + QString getKnownMissingFeatures() override + { + return {}; + } +}; + +/** + * @brief Feeds a login response to RemoteClient without a socket. + * + * `loginResponse` is the slot the socket thread runs for the login command's answer, so + * invoking it directly exercises the status transition that decides whether `readData()` + * tears the connection down right after it. + */ +class ClientLoginStatusTest : public ::testing::Test +{ +protected: + void SetUp() override + { + client = new RemoteClient(nullptr, &provider); + } + + void TearDown() override + { + delete client; + client = nullptr; + } + + void deliverLoginResponse(Response::ResponseCode code) + { + Response response; + response.set_response_code(code); + response.MutableExtension(Response_Login::ext); + ASSERT_TRUE( + QMetaObject::invokeMethod(client, "loginResponse", Qt::DirectConnection, Q_ARG(Response, response))); + } + + StubNetworkSettingsProvider provider; + RemoteClient *client = nullptr; +}; + +/** + * @brief A required password change is not a failed login. + * + * The server keeps the session open and accepts only ping and Command_AccountPassword until + * the password is replaced. StatusDisconnecting would make readData() close the socket right + * after this slot returns - before the dialog that collects the new password (queued to the + * UI thread) can send anything - and the pending command would never be answered because the + * ping timer that times it out dies with the connection. StatusLoggedIn would start the + * normal post-login flow for a session that cannot serve it. + */ +TEST_F(ClientLoginStatusTest, PasswordChangeRequiredKeepsTheSessionOpen) +{ + deliverLoginResponse(Response::RespPasswordChangeRequired); + + EXPECT_EQ(client->getStatus(), StatusPasswordChangeRequired); + EXPECT_NE(client->getStatus(), StatusDisconnecting); + EXPECT_NE(client->getStatus(), StatusLoggedIn); +} + +/** @brief Every other login error still ends the connection, as it did before. */ +TEST_F(ClientLoginStatusTest, OtherLoginErrorsDisconnect) +{ + deliverLoginResponse(Response::RespWrongPassword); + + EXPECT_EQ(client->getStatus(), StatusDisconnecting); +} + +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_forced_password_change_test.cpp b/tests/server_forced_password_change_test.cpp new file mode 100644 index 000000000..9717b610b --- /dev/null +++ b/tests/server_forced_password_change_test.cpp @@ -0,0 +1,234 @@ +/** @file server_forced_password_change_test.cpp + * @brief Tests for the restricted session a forced password change leaves behind at login. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_protocolhandler.h" + +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return PasswordChangeRequired; + } + ServerInfo_User getUserData(const QString &name, bool withId) override + { + ServerInfo_User user; + user.set_name(name.toStdString()); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator); + if (withId) { + user.set_id(1); + } + return user; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } +}; + +class FakeServer : public Server +{ +public: + FakeServer() + { + setDatabaseInterface(new MockDatabaseInterface()); + } +}; + +class TestHandler : public Server_ProtocolHandler +{ +public: + TestHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + Response::ResponseCode lastResponseCode = Response::RespNothing; + int lastExtendedSessionCommand = -1; + +protected: + Response::ResponseCode + processExtendedSessionCommand(int cmdType, const SessionCommand & /*cmd*/, ResponseContainer & /*rc*/) override + { + lastExtendedSessionCommand = cmdType; + return Response::RespOk; + } + + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } +}; + +class ForcedPasswordChangeTest : public ::testing::Test +{ +protected: + FakeServer server; + TestHandler handler{&server, server.getDatabaseInterface()}; + + void login() + { + CommandContainer cont; + cont.set_cmd_id(1); + Command_Login cmd; + cmd.set_user_name("mod"); + cont.add_session_command()->MutableExtension(Command_Login::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + Response::ResponseCode sendPing() + { + CommandContainer cont; + cont.set_cmd_id(2); + cont.add_session_command()->MutableExtension(Command_Ping::ext); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendSessionCommand() + { + CommandContainer cont; + cont.set_cmd_id(3); + cont.add_session_command()->MutableExtension(Command_ListRooms::ext); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendRoomCommand() + { + CommandContainer cont; + cont.set_cmd_id(4); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello"); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendMixedSessionAndRoomCommand() + { + CommandContainer cont; + cont.set_cmd_id(5); + cont.add_session_command()->MutableExtension(Command_Ping::ext); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello"); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendAccountPassword() + { + CommandContainer cont; + cont.set_cmd_id(6); + Command_AccountPassword cmd; + cmd.set_old_password("temporary-password"); + cmd.set_new_password("replacement-password"); + cont.add_session_command()->MutableExtension(Command_AccountPassword::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } +}; + +TEST_F(ForcedPasswordChangeTest, LoginReportsTheRequiredChange) +{ + login(); + + EXPECT_EQ(Response::RespPasswordChangeRequired, handler.lastResponseCode); + ASSERT_NE(nullptr, handler.getUserInfo()); + EXPECT_EQ("mod", QString::fromStdString(handler.getUserInfo()->name())); +} + +TEST_F(ForcedPasswordChangeTest, PendingSessionIsNotRegisteredAsAUser) +{ + login(); + // The session may change its password, but it must not take a user slot or appear in + // the session table before the client reconnects with the new password. + EXPECT_FALSE(handler.getUserInfo()->has_session_id()); + EXPECT_EQ(0, server.getUsersCount()); +} + +TEST_F(ForcedPasswordChangeTest, PendingModeratorIsNotReportedOnline) +{ + login(); + server.addClient(&handler); + + EXPECT_TRUE(server.getOnlineModeratorList().isEmpty()); +} + +TEST_F(ForcedPasswordChangeTest, OnlyPingAndPasswordChangeAreServed) +{ + login(); + + EXPECT_EQ(Response::RespOk, sendPing()); + EXPECT_EQ(Response::RespFunctionNotAllowed, sendSessionCommand()); + EXPECT_EQ(Response::RespFunctionNotAllowed, sendRoomCommand()); + // Dispatch prefers game/room over session, so a container carrying both must not slip past + // the restriction by carrying a session command. + EXPECT_EQ(Response::RespFunctionNotAllowed, sendMixedSessionAndRoomCommand()); +} + +TEST_F(ForcedPasswordChangeTest, AccountPasswordReachesTheCommand) +{ + login(); + // The forced change is useless if the gate refuses the command it needs. ACCOUNT_PASSWORD is + // served by the extended-command hook, so it must arrive there instead of being filtered. + EXPECT_EQ(Response::RespOk, sendAccountPassword()); + EXPECT_EQ(SessionCommand::ACCOUNT_PASSWORD, handler.lastExtendedSessionCommand); +} + +TEST_F(ForcedPasswordChangeTest, CompletedLoginIsStillReportedOnline) +{ + TestHandler loggedIn{&server, server.getDatabaseInterface()}; + ServerInfo_User user; + user.set_name("online-mod"); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator); + user.set_session_id(7); + loggedIn.setUserInfo(user); + server.addClient(&loggedIn); + + const QList moderators = server.getOnlineModeratorList(); + ASSERT_EQ(1, moderators.size()); + EXPECT_EQ("online-mod", moderators.first()); +} + +} // namespace From 5fe7f186d39ddd0db7210f7f5bf48ea2e6021162 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:01:55 +0100 Subject: [PATCH 175/247] [CI] Make the hdiutil retry wrapper report failure (#7643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Make the hdiutil retry wrapper report failure CPack reads the wrapper's exit status to decide whether the disk image was created, so a zero exit turns a failed hdiutil into a later "Problem copying the package" error that hides hdiutil's own message. - Propagate hdiutil's real status instead of collapsing every failure to 1. - Name the failing subcommand (create/attach/detach/convert) so the message says which CPack step gave up. - Count attempts rather than failures in the retry line. * Update .ci/compile.sh Co-authored-by: tooomm --------- Co-authored-by: Lukas Brübach Co-authored-by: tooomm --- .ci/compile.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index bf5a169af..9be8ba741 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -311,16 +311,24 @@ if [[ $RUNNER_OS == macOS ]]; then if [[ $MAKE_PACKAGE ]]; then # Workaround https://github.com/actions/runner-images/issues/7522 - # have hdiutil repeat the command 10 times in hope of success + # have hdiutil repeat the command 10 times in hope of success. + # The wrapper has to exit non-zero once it gives up: CPack reads that status to + # decide whether the disk image was created, so a zero exit turns a failed hdiutil + # into a much later, misleading "Problem copying the package" error that hides + # hdiutil's own message. hdiutil_script="/tmp/hdiutil.sh" # shellcheck disable=SC2016 echo '#!/bin/bash + attempts=10 i=0 - while ! hdiutil "$@"; do - if (( ++i >= 10 )); then - echo "Error: hdiutil failed $i times!" >&2 - break + while true; do + hdiutil "$@" && exit 0 + rc=$? + if (( ++i >= attempts )); then + echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2 + exit "$rc" fi + echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2 sleep 1 done' >"$hdiutil_script" chmod +x "$hdiutil_script" From a74dfeef6ec30c29e9f6550fe43cf1ed24cb58a9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:06:47 +0100 Subject: [PATCH 176/247] [Build] Configure AUTOMOC, AUTOUIC and AUTORCC once (#7637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root already set CMAKE_AUTOMOC; add CMAKE_AUTOUIC and CMAKE_AUTORCC next to it and drop the per-directory copies from 18 CMakeLists files. These variables are inherited by the subdirectories, so the duplicates never changed anything. qt_standard_project_setup is not used: besides the AUTO* variables it sets CMAKE_RUNTIME_OUTPUT_DIRECTORY on Windows, which breaks the install(DIRECTORY "$") copies and the per-app symbol paths, and appends to CMAKE_INSTALL_RPATH on Linux. Co-authored-by: Lukas Brübach --- CMakeLists.txt | 6 ++++++ libcockatrice_card/CMakeLists.txt | 4 ---- libcockatrice_deck_list/CMakeLists.txt | 4 ---- libcockatrice_filters/CMakeLists.txt | 4 ---- libcockatrice_game/CMakeLists.txt | 4 ---- libcockatrice_interfaces/CMakeLists.txt | 4 ---- libcockatrice_models/CMakeLists.txt | 4 ---- .../libcockatrice/models/database/CMakeLists.txt | 4 ---- .../libcockatrice/models/deck_list/CMakeLists.txt | 4 ---- libcockatrice_network/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/abstract/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/local/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/remote/CMakeLists.txt | 4 ---- .../libcockatrice/network/server/CMakeLists.txt | 4 ---- .../libcockatrice/network/server/local/CMakeLists.txt | 4 ---- .../libcockatrice/network/server/remote/CMakeLists.txt | 4 ---- libcockatrice_rng/CMakeLists.txt | 4 ---- libcockatrice_settings/CMakeLists.txt | 4 ---- libcockatrice_utility/CMakeLists.txt | 4 ---- 20 files changed, 6 insertions(+), 76 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d9acabc5e..b00e927a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -259,7 +259,13 @@ else() add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400) endif() +# Set once here for every target: these variables are inherited by the +# subdirectories, so the per-directory copies were redundant. Only the +# variables are set; qt_standard_project_setup would also change the runtime +# output directories and the install RPATH. set(CMAKE_AUTOMOC TRUE) +set(CMAKE_AUTOUIC TRUE) +set(CMAKE_AUTORCC TRUE) # Find other needed libraries find_package(Protobuf CONFIG) diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index e2d5029ed..d2e6ee83a 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index ffd4c28a1..3e7d522c2 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/deck_list/tree/abstract_deck_list_card_node.h libcockatrice/deck_list/tree/abstract_deck_list_node.h diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index df2ead83f..070c04637 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) diff --git a/libcockatrice_game/CMakeLists.txt b/libcockatrice_game/CMakeLists.txt index e7d362ca2..ce68c5ea6 100644 --- a/libcockatrice_game/CMakeLists.txt +++ b/libcockatrice_game/CMakeLists.txt @@ -1,7 +1,3 @@ -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 diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index 16ea686b5..69ed13b0e 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/interfaces/interface_cache_storage_settings_provider.h libcockatrice/interfaces/interface_card_database_path_provider.h diff --git a/libcockatrice_models/CMakeLists.txt b/libcockatrice_models/CMakeLists.txt index 7af35b2d5..5186eff13 100644 --- a/libcockatrice_models/CMakeLists.txt +++ b/libcockatrice_models/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(libcockatrice/models/database) add_subdirectory(libcockatrice/models/deck_list) diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index 2471adacf..5cf3caa52 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS card_database_model.h card_database_display_model.h diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index b46275437..ce5c4d54b 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) add_library( diff --git a/libcockatrice_network/CMakeLists.txt b/libcockatrice_network/CMakeLists.txt index 3069c0db4..925e4d445 100644 --- a/libcockatrice_network/CMakeLists.txt +++ b/libcockatrice_network/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(libcockatrice/network/client) add_subdirectory(libcockatrice/network/server) diff --git a/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt index bcf62463c..cf8d9fc4d 100644 --- a/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(abstract) add_subdirectory(local) add_subdirectory(remote) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 791db6348..33812e0bf 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS abstract_client.h latency_tracker.h) set(SOURCES abstract_client.cpp latency_tracker.cpp) diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 4d5676fc0..3eff7943a 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS local_client.h) set(SOURCES local_client.cpp) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index ecc822b7c..006c18d42 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS remote_client.h) set(SOURCES remote_client.cpp) diff --git a/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt index cbb717ad8..b59d61572 100644 --- a/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(local) add_subdirectory(remote) diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index c5f8ee432..4055efa64 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS local_server.h local_server_interface.h) set(SOURCES local_server.cpp local_server_interface.cpp) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 26c2be69a..8586a7bc8 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS game/dump_zone_range.h game/server_abstract_participant.h diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index c5b19ae85..77165122c 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h) add_library( diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index d23fb3350..a3b9a68db 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/settings/appearance_settings.h libcockatrice/settings/cache_storage_settings.h diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 55f1daf24..8d2cb5b92 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -1,10 +1,6 @@ cmake_minimum_required(VERSION 3.16) project(Utility VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}") -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(UTILITY_SOURCES libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp From 429eb2a1fd68de89714db224ac2af4f7ea3c04dc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:13:21 +0100 Subject: [PATCH 177/247] [Servatrice] Use compile-time checked connections (#7631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Use compile-time checked connections Replace the remaining SIGNAL/SLOT string connects in servatrice.cpp, smtpclient.cpp, serversocketinterface.cpp and isl_interface.cpp with pointer-to-member or lambda connects, and convert the string invokeMethod calls to the functor overload. QxtSmtp has overloaded signals, so those need qOverload. Slots inherited as protected members and the QWebSocket error connection owned by the ISL fix are left on lambdas/strings as appropriate; the vendored qxtsmtp.cpp is untouched. * Fix remaining review threads on the connection syntax - Connect the IslInterface signals straight to the inherited Server slots instead of forwarding lambdas. - Capture servatrice explicitly in the queued admin lambdas so they do not read this->servatrice after teardown. - Move the websocket newConnection connect out of the per-pool loop. - Terminate the logging category and restore pendingKey's indentation. --------- Co-authored-by: Lukas Brübach --- servatrice/src/isl_interface.cpp | 4 +- servatrice/src/servatrice.cpp | 79 ++++++++++++------------ servatrice/src/serversocketinterface.cpp | 31 +++++----- servatrice/src/smtpclient.cpp | 35 +++++------ 4 files changed, 76 insertions(+), 73 deletions(-) diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 8635808ac..1898cd5fa 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -58,9 +58,9 @@ void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privat socket->setLocalCertificate(cert); socket->setPrivateKey(privateKey); - connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()), Qt::QueuedConnection); + connect(socket, &QSslSocket::readyRead, this, &IslInterface::readClient, Qt::QueuedConnection); connect(socket, &QAbstractSocket::errorOccurred, this, &IslInterface::catchSocketError); - connect(this, SIGNAL(outputBufferChanged()), this, SLOT(flushOutputBuffer()), Qt::QueuedConnection); + connect(this, &IslInterface::outputBufferChanged, this, &IslInterface::flushOutputBuffer, Qt::QueuedConnection); } IslInterface::IslInterface(int _socketDescriptor, diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 49d8e1e4c..afe028f32 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -100,8 +100,10 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, newThread->start(); server->watchWorkerThread(newThread); - QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, - Q_ARG(QSqlDatabase, _sqlDatabase)); + QMetaObject::invokeMethod( + newDatabaseInterface, + [newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); }, + Qt::BlockingQueuedConnection); connectionPools.append(newPool); } @@ -125,9 +127,10 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor) auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface()); ssi->moveToThread(pool->thread()); pool->addClient(); - connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); + connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient); - QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(int, socketDescriptor)); + QMetaObject::invokeMethod( + ssi, [ssi, socketDescriptor] { ssi->initConnection(socketDescriptor); }, Qt::QueuedConnection); } Servatrice_ConnectionPool *Servatrice_GameServer::findLeastUsedConnectionPool() @@ -168,13 +171,15 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv newThread->start(); server->watchWorkerThread(newThread); - QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, - Q_ARG(QSqlDatabase, _sqlDatabase)); + QMetaObject::invokeMethod( + newDatabaseInterface, + [newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); }, + Qt::BlockingQueuedConnection); connectionPools.append(newPool); - - connect(this, SIGNAL(newConnection()), this, SLOT(onNewConnection())); } + + connect(this, &QWebSocketServer::newConnection, this, &Servatrice_WebsocketGameServer::onNewConnection); } Servatrice_WebsocketGameServer::~Servatrice_WebsocketGameServer() @@ -199,9 +204,10 @@ void Servatrice_WebsocketGameServer::onNewConnection() */ // ssi->moveToThread(pool->thread()); pool->addClient(); - connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); + connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient); - QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(void *, nextPendingConnection())); + void *socket = nextPendingConnection(); + QMetaObject::invokeMethod(ssi, [ssi, socket] { ssi->initConnection(socket); }, Qt::QueuedConnection); } Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnectionPool() @@ -224,14 +230,14 @@ Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnecti void Servatrice_IslServer::incomingConnection(qintptr socketDescriptor) { auto thread = new QThread; - connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); auto interface = new IslInterface(static_cast(socketDescriptor), cert, privateKey, server); interface->moveToThread(thread); - connect(interface, SIGNAL(destroyed()), thread, SLOT(quit())); + connect(interface, &QObject::destroyed, thread, &QThread::quit); thread->start(); - QMetaObject::invokeMethod(interface, "initServer", Qt::QueuedConnection); + QMetaObject::invokeMethod(interface, [interface] { interface->initServer(); }, Qt::QueuedConnection); } Servatrice::Servatrice(QObject *parent) @@ -585,15 +591,16 @@ bool Servatrice::initServer() auto *thread = new QThread; thread->setObjectName("isl_" + QString::number(prop.id)); - connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); IslInterface *interface = new IslInterface(prop.id, prop.hostname, prop.address.toString(), prop.controlPort, prop.cert, cert, key, this); interface->moveToThread(thread); - connect(interface, SIGNAL(destroyed()), thread, SLOT(quit())); + connect(interface, &QObject::destroyed, thread, &QThread::quit); thread->start(); - QMetaObject::invokeMethod(interface, "initClient", Qt::BlockingQueuedConnection); + QMetaObject::invokeMethod( + interface, [interface] { interface->initClient(); }, Qt::BlockingQueuedConnection); } qDebug() << "Starting ISL server on port" << getISLNetworkPort(); @@ -610,18 +617,18 @@ bool Servatrice::initServer() } pingClock = new QTimer(this); - connect(pingClock, SIGNAL(timeout()), this, SIGNAL(pingClockTimeout())); + connect(pingClock, &QTimer::timeout, this, &Servatrice::pingClockTimeout); pingClock->start(getClientKeepAlive() * 1000); statusUpdateClock = new QTimer(this); - connect(statusUpdateClock, SIGNAL(timeout()), this, SLOT(statusUpdate())); + connect(statusUpdateClock, &QTimer::timeout, this, &Servatrice::statusUpdate); if (getServerStatusUpdateTime() != 0) { qDebug() << "Starting status update clock, interval" << getServerStatusUpdateTime() << "ms"; statusUpdateClock->start(getServerStatusUpdateTime()); } deckShareCleanupClock = new QTimer(this); - connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + connect(deckShareCleanupClock, &QTimer::timeout, this, &Servatrice::cleanupExpiredDeckShares); const int deckShareCleanupInterval = getDeckShareCleanupInterval(); if (deckShareCleanupInterval > 0) { qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; @@ -1038,7 +1045,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes) nextShutdownMessageMinutes = shutdownMinutes; if (minutes > 0) { shutdownTimer = new QTimer; - connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout())); + connect(shutdownTimer, &QTimer::timeout, this, &Servatrice::shutdownTimeout); shutdownTimer->start(60000); } shutdownStateMutex.unlock(); @@ -1101,25 +1108,19 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface) { // Only call with islLock locked for writing islInterfaces.insert(_serverId, interface); - connect(interface, SIGNAL(externalUserJoined(ServerInfo_User)), this, SLOT(externalUserJoined(ServerInfo_User))); - connect(interface, SIGNAL(externalUserLeft(QString)), this, SLOT(externalUserLeft(QString))); - connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this, - SLOT(externalRoomUserJoined(int, ServerInfo_User))); - connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString))); - connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this, - SLOT(externalRoomSay(int, QString, QString, QString))); - connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this, - SLOT(externalRoomRemoveMessages(int, QString, int))); - connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this, - SLOT(externalRoomGameListChanged(int, ServerInfo_Game))); - connect(interface, SIGNAL(joinGameCommandReceived(Command_JoinGame, int, int, int, qint64)), this, - SLOT(externalJoinGameCommandReceived(Command_JoinGame, int, int, int, qint64))); - connect(interface, SIGNAL(gameCommandContainerReceived(CommandContainer, int, int, qint64)), this, - SLOT(externalGameCommandContainerReceived(CommandContainer, int, int, qint64))); - connect(interface, SIGNAL(responseReceived(Response, qint64)), this, - SLOT(externalResponseReceived(Response, qint64))); - connect(interface, SIGNAL(gameEventContainerReceived(GameEventContainer, qint64)), this, - SLOT(externalGameEventContainerReceived(GameEventContainer, qint64))); + connect(interface, &IslInterface::externalUserJoined, this, &Servatrice::externalUserJoined); + connect(interface, &IslInterface::externalUserLeft, this, &Servatrice::externalUserLeft); + connect(interface, &IslInterface::externalRoomUserJoined, this, &Servatrice::externalRoomUserJoined); + connect(interface, &IslInterface::externalRoomUserLeft, this, &Servatrice::externalRoomUserLeft); + connect(interface, &IslInterface::externalRoomSay, this, &Servatrice::externalRoomSay); + connect(interface, &IslInterface::externalRoomRemoveMessages, this, &Servatrice::externalRoomRemoveMessages); + connect(interface, &IslInterface::externalRoomGameListChanged, this, &Servatrice::externalRoomGameListChanged); + connect(interface, &IslInterface::joinGameCommandReceived, this, &Servatrice::externalJoinGameCommandReceived); + connect(interface, &IslInterface::gameCommandContainerReceived, this, + &Servatrice::externalGameCommandContainerReceived); + connect(interface, &IslInterface::responseReceived, this, &Servatrice::externalResponseReceived); + connect(interface, &IslInterface::gameEventContainerReceived, this, + &Servatrice::externalGameEventContainerReceived); } void Servatrice::removeIslInterface(int _serverId) diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index f0c0c3536..67e807380 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -199,7 +199,8 @@ AbstractServerSocketInterface::AbstractServerSocketInterface(Servatrice *_server // Never call flushOutputQueue directly from outputQueueChanged. In case of a socket error, // it could lead to this object being destroyed while another function is still on the call stack. -> mutex // deadlocks etc. - connect(this, SIGNAL(outputQueueChanged()), this, SLOT(flushOutputQueue()), Qt::QueuedConnection); + connect(this, &AbstractServerSocketInterface::outputQueueChanged, this, + &AbstractServerSocketInterface::flushOutputQueue, Qt::QueuedConnection); } bool AbstractServerSocketInterface::initSession() @@ -1982,7 +1983,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com SessionEvent *se = userList[i]->prepareSessionEvent(event); userList[i]->sendProtocolItem(*se); delete se; - QMetaObject::invokeMethod(userList[i], "prepareDestroy", Qt::QueuedConnection); + QMetaObject::invokeMethod( + userList[i], [user = userList[i]] { user->prepareDestroy(); }, Qt::QueuedConnection); } } @@ -4191,15 +4193,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdUpdateServerMessage(const Command_UpdateServerMessage & /*cmd*/, ResponseContainer & /*rc*/) { - QMetaObject::invokeMethod(server, "updateLoginMessage"); + QMetaObject::invokeMethod(servatrice, [srv = servatrice] { srv->updateLoginMessage(); }); return Response::RespOk; } Response::ResponseCode AbstractServerSocketInterface::cmdShutdownServer(const Command_ShutdownServer &cmd, ResponseContainer & /*rc*/) { - QMetaObject::invokeMethod(server, "scheduleShutdown", Q_ARG(QString, textFromStdString(cmd.reason())), - Q_ARG(int, cmd.minutes())); + QMetaObject::invokeMethod(servatrice, [srv = servatrice, reason = textFromStdString(cmd.reason()), + minutes = cmd.minutes()] { srv->scheduleShutdown(reason, minutes); }); return Response::RespOk; } @@ -4208,7 +4210,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReloadConfig(const Comm { logDebugMessage("Received admin command: reloading configuration"); settingsCache->sync(); - QMetaObject::invokeMethod(server, "setRequiredFeatures", Q_ARG(QString, server->getRequiredFeatures())); + QMetaObject::invokeMethod(servatrice, [srv = servatrice, features = servatrice->getRequiredFeatures()] { + srv->setRequiredFeatures(features); + }); return Response::RespOk; } @@ -4260,7 +4264,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN delete se; } - QMetaObject::invokeMethod(user, "prepareDestroy", Qt::QueuedConnection); + QMetaObject::invokeMethod(user, [user] { user->prepareDestroy(); }, Qt::QueuedConnection); return true; } @@ -4435,10 +4439,10 @@ TcpServerSocketInterface::TcpServerSocketInterface(Servatrice *_server, { socket = new QTcpSocket(this); socket->setSocketOption(QAbstractSocket::LowDelayOption, 1); - connect(socket, SIGNAL(readyRead()), this, SLOT(readClient())); - connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); - connect(socket, SIGNAL(errorOccurred(QAbstractSocket::SocketError)), this, - SLOT(catchSocketError(QAbstractSocket::SocketError))); + connect(socket, &QTcpSocket::readyRead, this, [this] { readClient(); }); + connect(socket, &QTcpSocket::disconnected, this, [this] { catchSocketDisconnected(); }); + connect(socket, &QTcpSocket::errorOccurred, this, + [this](QAbstractSocket::SocketError error) { catchSocketError(error); }); } TcpServerSocketInterface::~TcpServerSocketInterface() @@ -4660,8 +4664,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) } } - connect(socket, SIGNAL(binaryMessageReceived(const QByteArray &)), this, - SLOT(binaryMessageReceived(const QByteArray &))); + connect(socket, &QWebSocket::binaryMessageReceived, this, &WebsocketServerSocketInterface::binaryMessageReceived); // QWebSocket::errorOccurred was added in Qt 6.5 (error() is deprecated from 6.5 on), // so a typed connect cannot compile against the 6.4 floor. #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) @@ -4671,7 +4674,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, SLOT(catchSocketError(QAbstractSocket::SocketError))); #endif - connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); + connect(socket, &QWebSocket::disconnected, this, [this] { catchSocketDisconnected(); }); // Add this object to the server's list of connections before it can receive socket events. // Otherwise, in case of a socket error, it could be removed from the list before it is added. diff --git a/servatrice/src/smtpclient.cpp b/servatrice/src/smtpclient.cpp index 82824e496..bfdfae905 100644 --- a/servatrice/src/smtpclient.cpp +++ b/servatrice/src/smtpclient.cpp @@ -25,7 +25,7 @@ static const qint64 IDLE_TIMEOUT_MS = 60000; // 60 seconds // MAX_RETRY_DELAY cap is applied, preventing unbounded intermediate values. static const int MAX_BACKOFF_SHIFT = 10; -inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client") +inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client"); static QString pendingKey(const QString &userName, EmailType type, const QString &token) { @@ -55,24 +55,23 @@ SmtpClient::SmtpClient(QObject *parent) : QObject(parent) idleTimer = new QTimer(this); idleTimer->setSingleShot(true); - connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleTimeout())); + connect(idleTimer, &QTimer::timeout, this, &SmtpClient::idleTimeout); - connect(smtp, SIGNAL(authenticated()), this, SLOT(authenticated())); - connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this, - SLOT(authenticationFailed(const QByteArray &))); - connect(smtp, SIGNAL(connected()), this, SLOT(connected())); - connect(smtp, SIGNAL(connectionFailed(const QByteArray &)), this, SLOT(connectionFailed(const QByteArray &))); - connect(smtp, SIGNAL(disconnected()), this, SLOT(disconnected())); - connect(smtp, SIGNAL(encrypted()), this, SLOT(encrypted())); - connect(smtp, SIGNAL(encryptionFailed(const QByteArray &)), this, SLOT(encryptionFailed(const QByteArray &))); - connect(smtp, SIGNAL(finished()), this, SLOT(finished())); - connect(smtp, SIGNAL(mailFailed(int, int, const QByteArray &)), this, - SLOT(mailFailed(int, int, const QByteArray &))); - connect(smtp, SIGNAL(mailSent(int)), this, SLOT(mailSent(int))); - connect(smtp, SIGNAL(recipientRejected(int, const QString &, const QByteArray &)), this, - SLOT(recipientRejected(int, const QString &, const QByteArray &))); - connect(smtp, SIGNAL(senderRejected(int, const QString &, const QByteArray &)), this, - SLOT(senderRejected(int, const QString &, const QByteArray &))); + connect(smtp, &QxtSmtp::authenticated, this, &SmtpClient::authenticated); + connect(smtp, qOverload(&QxtSmtp::authenticationFailed), this, + &SmtpClient::authenticationFailed); + connect(smtp, &QxtSmtp::connected, this, &SmtpClient::connected); + connect(smtp, qOverload(&QxtSmtp::connectionFailed), this, &SmtpClient::connectionFailed); + connect(smtp, &QxtSmtp::disconnected, this, &SmtpClient::disconnected); + connect(smtp, &QxtSmtp::encrypted, this, &SmtpClient::encrypted); + connect(smtp, qOverload(&QxtSmtp::encryptionFailed), this, &SmtpClient::encryptionFailed); + connect(smtp, &QxtSmtp::finished, this, &SmtpClient::finished); + connect(smtp, qOverload(&QxtSmtp::mailFailed), this, &SmtpClient::mailFailed); + connect(smtp, &QxtSmtp::mailSent, this, &SmtpClient::mailSent); + connect(smtp, qOverload(&QxtSmtp::recipientRejected), this, + &SmtpClient::recipientRejected); + connect(smtp, qOverload(&QxtSmtp::senderRejected), this, + &SmtpClient::senderRejected); } SmtpClient::~SmtpClient() From ce543896f535e98273934436dfcde8ee603a1368 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:27:43 +0100 Subject: [PATCH 178/247] [Tests] Use QtTest for model contracts and signal spies (#7630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt6::Test was linked into every test but never used. Add a small RAII QAbstractItemModelTester helper and apply it to the deck list model, the card database model and display proxy, and the sets model. Add QSignalSpy coverage for the card-added and download-settings signals, and assert explicitly that a card row is a leaf with no child columns (the tester does not check that). The contract checker immediately found four model bugs, fixed here: - DeckListModel allowed children below non-zero columns. - CardDatabaseModel reported every card as a child of every card row. - CardDatabaseModel reported six columns for a valid (leaf) card index. - CardDatabaseDisplayModel claimed more rows were fetchable while rowCount already exposed them all, so fetchMore() inserted duplicates. Co-authored-by: Lukas Brübach --- .../visual_database_display_widget.cpp | 5 - .../database/card_database_display_model.cpp | 33 +---- .../database/card_database_display_model.h | 4 - .../models/database/card_database_model.cpp | 12 +- .../models/deck_list/deck_list_model.cpp | 9 +- tests/CMakeLists.txt | 1 + tests/model_contract/CMakeLists.txt | 17 +++ tests/model_contract/model_contract_test.cpp | 137 ++++++++++++++++++ tests/support/model_contract_checker.h | 35 +++++ 9 files changed, 207 insertions(+), 46 deletions(-) create mode 100644 tests/model_contract/CMakeLists.txt create mode 100644 tests/model_contract/model_contract_test.cpp create mode 100644 tests/support/model_contract_checker.h diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 5a1097b0d..30e0af66b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -417,11 +417,6 @@ void VisualDatabaseDisplayWidget::loadNextPage() int start = currentPage * cardsPerPage; int end = qMin(start + cardsPerPage, rowCount); - // Load more cards if we are at the end of the current list and can fetch more - if (end >= rowCount && databaseDisplayModel->canFetchMore(QModelIndex())) { - databaseDisplayModel->fetchMore(QModelIndex()); - } - // Load the next page of cards and add them to the flow widget flowWidget->setUpdatesEnabled(false); loadPage(start, end); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 5573deb18..6ed9d8827 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -33,8 +33,6 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) invalidate(); emit modelDirty(); }); - - loadedRowCount = 0; } void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) @@ -45,10 +43,7 @@ void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) connect(model, &QAbstractItemModel::rowsRemoved, this, [this]() { dirty(); }); - connect(model, &QAbstractItemModel::modelReset, this, [this]() { - loadedRowCount = 0; - dirty(); - }); + connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); }); } QMap CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'}, @@ -56,32 +51,6 @@ QMap CardDatabaseDisplayModel::characterTranslation = {{L'“' {L'‘', L'\''}, {L'’', L'\''}}; -bool CardDatabaseDisplayModel::canFetchMore(const QModelIndex &index) const -{ - return loadedRowCount < sourceModel()->rowCount(index); -} - -void CardDatabaseDisplayModel::fetchMore(const QModelIndex &index) -{ - int remainder = sourceModel()->rowCount(index) - loadedRowCount; - int itemsToFetch = qMin(100, remainder); - - if (itemsToFetch == 0) { - return; - } - - const auto startIndex = qMin(rowCount(QModelIndex()), loadedRowCount); - beginInsertRows(QModelIndex(), startIndex, startIndex + itemsToFetch - 1); - - loadedRowCount += itemsToFetch; - endInsertRows(); -} - -int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const -{ - return QSortFilterProxyModel::rowCount(parent); -} - bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index 343a7482e..be074bba5 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -42,7 +42,6 @@ private: QSet cardNameSet, cardTypes, cardColors; FilterTree *filterTree; FilterString *filterString; - int loadedRowCount; QTimer dirtyTimer; CardSearchLanguage searchLanguage; QString searchText; @@ -85,9 +84,6 @@ public: dirtyTimer.start(20); } void clearFilterAll(); - [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; - [[nodiscard]] bool canFetchMore(const QModelIndex &parent) const override; - void fetchMore(const QModelIndex &parent) override; signals: void modelDirty(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp index 1ede554f8..a83b7c83d 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -27,14 +27,18 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE CardDatabaseModel::~CardDatabaseModel() = default; -int CardDatabaseModel::rowCount(const QModelIndex & /*parent*/) const +int CardDatabaseModel::rowCount(const QModelIndex &parent) const { - return cardList.size(); + // A list model's card rows are leaves; returning the full card count for a + // valid parent makes every row claim all cards as children. + return parent.isValid() ? 0 : cardList.size(); } -int CardDatabaseModel::columnCount(const QModelIndex & /*parent*/) const +int CardDatabaseModel::columnCount(const QModelIndex &parent) const { - return CARDDBMODEL_COLUMNS; + // Card rows are leaves, so a valid parent holds no columns. Reporting the + // row width for a card index would claim child cells that do not exist. + return parent.isValid() ? 0 : CARDDBMODEL_COLUMNS; } QVariant CardDatabaseModel::data(const QModelIndex &index, int role) const diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 4b4088972..617324fe3 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -134,6 +134,13 @@ void DeckListModel::rebuildTree() int DeckListModel::rowCount(const QModelIndex &parent) const { + // Only column 0 carries children. Column 1 and later indexes are siblings + // used by views for the remaining cells of a row, and a model that hangs + // children off them breaks QAbstractItemModelTester and proxy models. + if (parent.isValid() && parent.column() != 0) { + return 0; + } + // debugIndexInfo("rowCount", parent); auto *node = getNode(parent); if (node) { @@ -282,7 +289,7 @@ QVariant DeckListModel::headerData(const int section, const Qt::Orientation orie QModelIndex DeckListModel::index(int row, int column, const QModelIndex &parent) const { // debugIndexInfo("index", parent); - if (!hasIndex(row, column, parent)) { + if ((parent.isValid() && parent.column() != 0) || !hasIndex(row, column, parent)) { return {}; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 00b39193d..261900c01 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -516,6 +516,7 @@ add_subdirectory(carddatabase) add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) +add_subdirectory(model_contract) add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) diff --git a/tests/model_contract/CMakeLists.txt b/tests/model_contract/CMakeLists.txt new file mode 100644 index 000000000..3251c1443 --- /dev/null +++ b/tests/model_contract/CMakeLists.txt @@ -0,0 +1,17 @@ +cockatrice_add_test( + NAME + model_contract_test + SOURCES + ${VERSION_STRING_CPP} + model_contract_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + libcockatrice_settings + INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../support + ${CMAKE_CURRENT_SOURCE_DIR}/../carddatabase + DEFINITIONS + CARDDB_DATADIR="${CMAKE_SOURCE_DIR}/tests/carddatabase/data/" +) diff --git a/tests/model_contract/model_contract_test.cpp b/tests/model_contract/model_contract_test.cpp new file mode 100644 index 000000000..aefa26f3d --- /dev/null +++ b/tests/model_contract/model_contract_test.cpp @@ -0,0 +1,137 @@ +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/models/database/card_database_display_model.h" +#include "libcockatrice/models/database/card_database_model.h" +#include "libcockatrice/models/database/card_set/card_sets_model.h" +#include "libcockatrice/models/deck_list/deck_list_model.h" +#include "model_contract_checker.h" +#include "test_card_database_path_provider.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QSharedPointer makeDeck() +{ + return QSharedPointer(new DeckList()); +} + +void loadCardDatabase(CardDatabase &database) +{ + database.loadCardDatabases(); + ASSERT_EQ(database.getLoadStatus(), Ok); +} + +TEST(ModelContract, DeckListModelKeepsContractWhileZonesAndCardsChange) +{ + const QSharedPointer deck = makeDeck(); + DeckListModel model(nullptr, deck); + + test_support::ModelContractChecker checker(&model); + + ASSERT_NE(deck->getTree()->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + CardInfoPtr bolt = CardInfo::newInstance("Lightning Bolt"); + const QModelIndex added = model.addCard(ExactCard(bolt), DECK_ZONE_MAIN); + ASSERT_TRUE(added.isValid()); + + CardInfoPtr bears = CardInfo::newInstance("Grizzly Bears"); + bears->setProperty(Mtg::MainCardType, "Creature"); + const QModelIndex grouped = model.addCard(ExactCard(bears), "Removal"); + ASSERT_TRUE(grouped.isValid()); + + ASSERT_TRUE(model.removeRows(grouped.row(), 1, grouped.parent())); +} + +TEST(ModelContract, DeckListModelEmitsCardAddedSignal) +{ + const QSharedPointer deck = makeDeck(); + DeckListModel model(nullptr, deck); + QSignalSpy spy(&model, &DeckListModel::cardAddedAt); + + const QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + + ASSERT_TRUE(added.isValid()); + EXPECT_EQ(spy.count(), 1); +} + +TEST(ModelContract, CardDatabaseModelAndDisplayProxyKeepContract) +{ + CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(), + new NoopCardSetPriorityController()); + ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database)); + + CardDatabaseModel source(&database, false); + test_support::ModelContractChecker sourceChecker(&source); + + CardDatabaseDisplayModel display; + display.setSourceModel(&source); + test_support::ModelContractChecker displayChecker(&display); + + ASSERT_GT(source.rowCount(), 0); + EXPECT_EQ(display.rowCount(), source.rowCount()); + + // A card row is a leaf, so its index must not claim child columns. The + // tester does not check this, hence the explicit assertion. + const QModelIndex sourceCard = source.index(0, 0); + ASSERT_TRUE(sourceCard.isValid()); + EXPECT_EQ(source.columnCount(sourceCard), 0); + const QModelIndex displayCard = display.index(0, 0); + ASSERT_TRUE(displayCard.isValid()); + EXPECT_EQ(display.columnCount(displayCard), 0); + + QSignalSpy dirtySpy(&display, &CardDatabaseDisplayModel::modelDirty); + display.setCardName("Do"); + ASSERT_TRUE(dirtySpy.wait()); + EXPECT_GT(display.rowCount(), 0); + EXPECT_LT(display.rowCount(), source.rowCount()); +} + +TEST(ModelContract, SetsModelKeepsContract) +{ + CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(), + new NoopCardSetPriorityController()); + ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database)); + + SetsModel model(&database); + test_support::ModelContractChecker checker(&model); + + ASSERT_GT(model.rowCount(), 0); + ASSERT_GT(model.columnCount(), 0); +} + +TEST(ModelContract, SettingsSignalIsObservableWithQSignalSpy) +{ + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + DownloadSettings settings(tempDir.path() + "/", nullptr); + QSignalSpy spy(&settings, &DownloadSettings::downloadSpoilerStatusChanged); + + settings.setDownloadSpoilerStatus(true); + + ASSERT_EQ(spy.count(), 1); +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/support/model_contract_checker.h b/tests/support/model_contract_checker.h new file mode 100644 index 000000000..9b493ff6e --- /dev/null +++ b/tests/support/model_contract_checker.h @@ -0,0 +1,35 @@ +#ifndef COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H +#define COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Keeps a QAbstractItemModelTester alive for the lifetime of a scope. + * + * The tester only observes the model while it exists, so construct this before + * the mutations a test wants to validate and keep it in scope until the end. + * Fatal reporting mode aborts on the first contract violation; a warning would + * let the test pass while printing a line nobody reads in CI. + */ +class ModelContractChecker +{ +public: + explicit ModelContractChecker(QAbstractItemModel *model) + : tester( + std::make_unique(model, QAbstractItemModelTester::FailureReportingMode::Fatal)) + { + } + + ModelContractChecker(const ModelContractChecker &) = delete; + ModelContractChecker &operator=(const ModelContractChecker &) = delete; + +private: + std::unique_ptr tester; +}; +} // namespace test_support + +#endif // COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H From d021f20d149ea9fd522b6b403f501be92a1f8cc6 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:28:14 +0100 Subject: [PATCH 179/247] [Version] Bump to 3.2.0 (#7762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b00e927a4..8a25fe2c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.1.2) +project("Cockatrice" VERSION 3.2.0) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) From 719863e43d1ecf1747dcd254cef7a234fd20cc4e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:29:05 +0100 Subject: [PATCH 180/247] [CI] Lint only .sh files in the shell formatting pass (#7715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changed-file matcher reused the `*.sh` glob as a regular expression, where the leading `*` applies to the `(^|/)` group instead of the file name. Every path ending in "sh" matched, so NSIS include files (.nsh) were handed to shellcheck and failed the format leg. Match the suffix explicitly and keep the glob for the find(1) branch. Co-authored-by: Lukas Brübach --- format.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/format.sh b/format.sh index b4c78543b..fba76e93a 100755 --- a/format.sh +++ b/format.sh @@ -33,6 +33,7 @@ cmakefile="CMakeLists.txt" cmakedir="cmake/.*\\.cmake" cmakeinclude=("cmake/gtest-CMakeLists.txt.in") scripts="*.sh" +scriptsrx="[^/]*\\.sh$" color="--" verbosity=0 sep="----------" @@ -254,7 +255,7 @@ if [[ $branch ]]; then if [[ $do_shell ]]; then shell_names=() for name in "${basenames[@]}"; do - filerx="(^|/)$scripts$" + filerx="(^|/)$scriptsrx" if [[ $name =~ $filerx ]]; then shell_names+=("$name") fi From b37c8b828f382af67380019a804efa2825d6a0c0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:29:15 +0100 Subject: [PATCH 181/247] [Utility] Budget scrypt's XY array in the derivation memory limit (#7712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveKey passes maxmem = 128nr + 128rp + 4096 to EVP_PBE_scrypt, but scrypt also allocates XY at 256r bytes. costParamsAreSane accepts r up to 32, so any server asking for r >= 17 made derivation fail and the client report a login abort it could never recover from. Cover the XY array and add a regression test at the accepted-range boundary. Co-authored-by: Lukas Brübach --- .../libcockatrice/utility/passwordhasher.cpp | 8 +++++--- tests/password_hash_test.cpp | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index a7106aadc..8e876d5bc 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -62,9 +62,11 @@ QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray & { QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0'); const QByteArray passwordUtf8 = password.toUtf8(); - // EVP_PBE_scrypt aborts unless maxmem covers the required working memory, - // which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array). - const auto maxmem = static_cast(128) * n * r + static_cast(128) * r * p + 4096; + // EVP_PBE_scrypt aborts unless maxmem covers the required working memory: V is 128 * n * + // r bytes, B is 128 * r * p, and XY is 256 * r. Under-budgeting XY makes OpenSSL fail + // for r >= 17, which costParamsAreSane still accepts. + const auto maxmem = + static_cast(128) * n * r + static_cast(128) * r * p + static_cast(256) * r + 4096; if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(), reinterpret_cast(salt.constData()), salt.size(), n, r, p, maxmem, reinterpret_cast(key.data()), key.size()) != 1) { diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 99f2e1400..8e7eded72 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -52,6 +52,17 @@ TEST(PasswordHashTest, DeriveKeyMatchesKnownVector) ASSERT_EQ(derived.toHex(), expected.toHex()); } +TEST(PasswordHashTest, DeriveKeyCoversTheWholeAcceptedRangeOfR) +{ + // costParamsAreSane accepts r up to 32. The memory budget handed to OpenSSL has to cover + // scrypt's XY array (256 * r bytes) on top of V and B, or derivation fails for r >= 17. + for (const int r : {17, 32}) { + const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, r, 1); + ASSERT_FALSE(derived.isEmpty()) << "deriveKey failed for accepted r=" << r; + ASSERT_EQ(derived.size(), SCRYPT_VERIFIER_LENGTH); + } +} + TEST(PasswordHashTest, PasswordVerifierRoundTrip) { const QString stored = PasswordHasher::generatePasswordVerifier("hunter2"); From 5134e72eb4da451e3065ed659d8fda1b9fe3eac9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:30:29 +0100 Subject: [PATCH 182/247] [Settings] Store server profiles with QSettings arrays (#7629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server list used hand-numbered saveName%1/server%1 keys and an O(n) shift loop to delete entries. Store the profiles with beginReadArray and beginWriteArray instead, and migrate the legacy keys once in the constructor so existing profiles and saved passwords survive. SettingsManager::batchWrite no longer disables atomic sync, so a batch is still written through QSettings' atomic save. Add tests for add, update, remove, password updates and the legacy migration. Co-authored-by: Lukas Brübach --- .../src/interface/intents/intent_login.cpp | 11 +- .../src/interface/intents/url_parser.cpp | 20 +- .../server/user/user_info_connection.cpp | 45 +- .../settings/servers_settings.cpp | 437 ++++++++++++------ .../libcockatrice/settings/servers_settings.h | 23 +- .../settings/settings_manager.cpp | 5 +- .../libcockatrice/settings/settings_manager.h | 4 +- tests/settings/CMakeLists.txt | 2 + tests/settings/servers_settings_test.cpp | 228 +++++++++ 9 files changed, 566 insertions(+), 209 deletions(-) create mode 100644 tests/settings/servers_settings_test.cpp diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index 0d8390015..8cc574cb0 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -27,9 +27,9 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(context->hostname, context->port); - if (index >= 0) { - context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + if (const auto server = servers.getServer(index)) { + context->username = server->username; + context->password = server->password; emitFinished(); } else { emitFailed(tr("No saved credentials for this server")); @@ -69,9 +69,8 @@ void IntentGetLoginCredentials::onPreconditionNotSatisfied() // it with the raw hostname when addNewServer updates the entry in place. QString saveName = context->hostname; const int existingIndex = servers.findServerIndex(context->hostname, context->port); - if (existingIndex >= 0) { - saveName = - servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString(); + if (const auto existing = servers.getServer(existingIndex)) { + saveName = existing->saveName; if (saveName.isEmpty()) { saveName = context->hostname; } diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 1e482a4f1..e0b7bb2fb 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -418,12 +418,10 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); - if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { - const QString username = - servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - const QString password = - servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); - client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + const auto server = servers.getServer(index); + if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username, + server->password); return; } client->disconnectFromServer(); @@ -438,11 +436,9 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) // when credentials are saved, otherwise stay offline. ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); - if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { - const QString username = - servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - const QString password = - servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); - client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + const auto server = servers.getServer(index); + if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username, + server->password); } } diff --git a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp index d23ac63ba..2e864824c 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp @@ -29,19 +29,10 @@ QMap> UserConnection_Inf ServersSettings &servers = SettingsCache::instance().servers(); - int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1; - - for (int i = 0; i < size; i++) { - QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString(); - QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString(); - QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString(); - QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString(); - bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool(); - QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString(); - - UserConnection_Information userInfo(_saveName, serverName, portNum, userName, pass, savePass, _site); - serverList.insert(_saveName, std::make_pair(serverName, userInfo)); + for (const auto &entry : servers.getServers()) { + UserConnection_Information userInfo(entry.saveName, entry.host, entry.port, entry.username, entry.password, + entry.savePassword, entry.site); + serverList.insert(entry.saveName, std::make_pair(entry.host, userInfo)); } return serverList; @@ -53,28 +44,18 @@ QStringList UserConnection_Information::getServerInfo(const QString &find) ServersSettings &servers = SettingsCache::instance().servers(); - int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1; - for (int i = 0; i < size; i++) { - QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString(); - - if (find != _saveName) { + for (const auto &entry : servers.getServers()) { + if (find != entry.saveName) { continue; } - QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString(); - QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString(); - QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString(); - bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool(); - QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString(); - - _server.append(_saveName); - _server.append(serverName); - _server.append(portNum); - _server.append(userName); - _server.append(pass); - _server.append(savePass ? "1" : "0"); - _server.append(_site); + _server.append(entry.saveName); + _server.append(entry.host); + _server.append(entry.port); + _server.append(entry.username); + _server.append(entry.password); + _server.append(entry.savePassword ? "1" : "0"); + _server.append(entry.site); break; } diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 4595da2a2..38b87b97c 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -5,16 +5,118 @@ #include #include #include -#include +#include +#include #include -#include +#include +#include #include -class QObject; +namespace +{ +constexpr auto SERVER_GROUP = "server"; +constexpr auto SERVER_DETAILS_GROUP = "server_details"; +constexpr auto SERVERS_ARRAY = "servers"; + +QString legacyKey(const char *field, int index) +{ + return QStringLiteral("%1/%2%3") + .arg(QString::fromLatin1(SERVER_DETAILS_GROUP), QString::fromLatin1(field)) + .arg(index); +} + +//! Expects \a settings to be positioned on the server group. +QList readServerArray(QSettings &settings) +{ + QList servers; + + settings.beginGroup(SERVER_DETAILS_GROUP); + const int size = settings.beginReadArray(SERVERS_ARRAY); + servers.reserve(size); + for (int i = 0; i < size; ++i) { + settings.setArrayIndex(i); + ServersSettings::ServerEntry entry; + entry.saveName = settings.value("saveName").toString(); + entry.host = settings.value("server").toString(); + entry.port = settings.value("port").toString(); + entry.username = settings.value("username").toString(); + entry.password = settings.value("password").toString(); + entry.savePassword = settings.value("savePassword").toBool(); + entry.site = settings.value("site").toString(); + servers.append(entry); + } + settings.endArray(); + settings.endGroup(); + + return servers; +} + +//! Expects \a settings to be positioned on the server group. +QList readLegacyServers(QSettings &settings, int lastIndex) +{ + QList servers; + + for (int i = 0; i <= lastIndex; ++i) { + ServersSettings::ServerEntry entry; + entry.saveName = settings.value(legacyKey("saveName", i)).toString(); + entry.host = settings.value(legacyKey("server", i)).toString(); + entry.port = settings.value(legacyKey("port", i)).toString(); + entry.username = settings.value(legacyKey("username", i)).toString(); + entry.password = settings.value(legacyKey("password", i)).toString(); + entry.savePassword = settings.value(legacyKey("savePassword", i)).toBool(); + entry.site = settings.value(legacyKey("site", i)).toString(); + + // Older builds could leave the unused index 0 behind. + if (!entry.saveName.isEmpty() || !entry.host.isEmpty()) { + servers.append(entry); + } + } + + return servers; +} + +void writeServerArray(QSettings &settings, const QList &servers) +{ + settings.beginGroup(SERVER_DETAILS_GROUP); + // beginWriteArray() never drops entries past the new size, so without this + // a removed profile would keep its slot - including its saved password - + // in servers.ini forever. The legacy keys live beside the array, not in it. + settings.remove(SERVERS_ARRAY); + settings.beginWriteArray(SERVERS_ARRAY, servers.size()); + for (int i = 0; i < servers.size(); ++i) { + settings.setArrayIndex(i); + const ServersSettings::ServerEntry &entry = servers.at(i); + settings.setValue("saveName", entry.saveName); + settings.setValue("server", entry.host); + settings.setValue("port", entry.port); + settings.setValue("username", entry.username); + settings.setValue("password", entry.password); + settings.setValue("savePassword", entry.savePassword); + settings.setValue("site", entry.site); + } + settings.endArray(); + settings.endGroup(); +} +} // namespace ServersSettings::ServersSettings(const QString &settingPath, QObject *parent) - : SettingsManager(settingPath + "servers.ini", "server", QString(), parent) + : SettingsManager(settingPath + "servers.ini", SERVER_GROUP, QString(), parent) { + migrateLegacyServers(); +} + +QList ServersSettings::getServers() const +{ + return loadServers(); +} + +std::optional ServersSettings::getServer(int index) const +{ + const QList servers = loadServers(); + if (index < 0 || index >= servers.size()) { + return std::nullopt; + } + return servers.at(index); } void ServersSettings::setPreviousHostLogin(int previous) @@ -45,16 +147,14 @@ void ServersSettings::setPrevioushostName(const QString &name) QString ServersSettings::getSaveName(QString defaultname) { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant saveName = getValue(QString("saveName%1").arg(index), "server", "server_details"); - return saveName == QVariant() ? std::move(defaultname) : saveName.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->saveName : std::move(defaultname); } QString ServersSettings::getSite(QString defaultSite) { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant site = getValue(QString("site%1").arg(index), "server", "server_details"); - return site == QVariant() ? std::move(defaultSite) : site.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->site : std::move(defaultSite); } QString ServersSettings::getPrevioushostName() const @@ -65,10 +165,9 @@ QString ServersSettings::getPrevioushostName() const int ServersSettings::getPrevioushostindex(const QString &saveName) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (saveName == getValue(QString("saveName%1").arg(i), "server", "server_details").toString()) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).saveName == saveName) { return i; } } @@ -78,43 +177,38 @@ int ServersSettings::getPrevioushostindex(const QString &saveName) const QString ServersSettings::getHostname(QString defaultHost) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant hostname = getValue(QString("server%1").arg(index), "server", "server_details"); - return hostname == QVariant() ? std::move(defaultHost) : hostname.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->host : std::move(defaultHost); } QString ServersSettings::getPort(QString defaultPort) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant port = getValue(QString("port%1").arg(index), "server", "server_details"); - qCDebug(ServersSettingsLog) << "getPort() index = " << index << " port.val = " << port.toString(); - return port == QVariant() ? std::move(defaultPort) : port.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + qCDebug(ServersSettingsLog) << "getPort() found server = " << server.has_value(); + return server ? server->port : std::move(defaultPort); } QString ServersSettings::getPlayerName(QString defaultName) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant name = getValue(QString("username%1").arg(index), "server", "server_details"); - qCDebug(ServersSettingsLog) << "getPlayerName() index = " << index << " name.val = " << name.toString(); - return name == QVariant() ? std::move(defaultName) : name.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + qCDebug(ServersSettingsLog) << "getPlayerName() found server = " << server.has_value(); + return server ? server->username : std::move(defaultName); } QString ServersSettings::getPassword() { - int index = getPrevioushostindex(getPrevioushostName()); - - if (getSavePassword()) { - return getValue(QString("password%1").arg(index), "server", "server_details").toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + if (!server || !server->savePassword) { + return QString(); } - return QString(); + return server->password; } bool ServersSettings::getSavePassword() const { - int index = getPrevioushostindex(getPrevioushostName()); - bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); - return save; + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server && server->savePassword; } void ServersSettings::setAutoConnect(int autoconnect) @@ -157,15 +251,19 @@ void ServersSettings::setFPPlayerName(QString playerName) void ServersSettings::setServerPassword(const QString &saveName, const QString &password) { - const int index = getPrevioushostindex(saveName); - if (index >= 0) { - setValue(password, QString("password%1").arg(index), "server", "server_details"); - } else { - // A credential write that silently no-ops is hard to diagnose from a - // bug report; surface a mismatched profile name instead. - qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName - << "- password not saved"; + QList servers = loadServers(); + for (ServerEntry &server : servers) { + if (server.saveName == saveName) { + server.password = password; + storeServers(servers); + return; + } } + + // A credential write that silently no-ops is hard to diagnose from a bug + // report; surface a mismatched profile name instead. + qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName + << "- password not saved"; } QString ServersSettings::getFPPlayerName(QString defaultName) const @@ -202,62 +300,29 @@ void ServersSettings::addNewServer(const QString &saveName, return; } - int index = getValue("totalServers", "server", "server_details").toInt() + 1; - - setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); - setValue(serv, QString("server%1").arg(index), "server", "server_details"); - setValue(port, QString("port%1").arg(index), "server", "server_details"); - setValue(username, QString("username%1").arg(index), "server", "server_details"); - setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); - setValue(index, "totalServers", "server", "server_details"); - setValue(password, QString("password%1").arg(index), "server", "server_details"); - setValue(site, QString("site%1").arg(index), "server", "server_details"); + QList servers = loadServers(); + ServerEntry entry; + entry.saveName = saveName; + entry.host = serv; + entry.port = port; + entry.username = username; + entry.password = password; + entry.savePassword = savePassword; + entry.site = site; + servers.append(entry); + storeServers(servers); } void ServersSettings::removeServer(QString servAddr) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - bool found = false; - for (int i = 0; i <= size; ++i) { - if (!found) { - // find entry and overwrite it - if (servAddr == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - found = true; - } - } else { - // move all other entries after it one back, overwriting the previous one - int previous = i - 1; // we delete only one entry - setValue(getValue(QString("server%1").arg(i), "server", "server_details"), - QString("server%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("port%1").arg(i), "server", "server_details"), QString("port%1").arg(previous), - "server", "server_details"); - setValue(getValue(QString("username%1").arg(i), "server", "server_details"), - QString("username%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("savePassword%1").arg(i), "server", "server_details"), - QString("savePassword%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("password%1").arg(i), "server", "server_details"), - QString("password%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("saveName%1").arg(i), "server", "server_details"), - QString("saveName%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("site%1").arg(i), "server", "server_details"), QString("site%1").arg(previous), - "server", "server_details"); + QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host == servAddr) { + servers.removeAt(i); + storeServers(servers); + return; } } - - // if we have deleted an entry, adjust the total - if (found) { - setValue(size - 1, "totalServers", "server", "server_details"); - - // delete last value - deleteValue(QString("server%1").arg(size), "server", "server_details"); - deleteValue(QString("port%1").arg(size), "server", "server_details"); - deleteValue(QString("username%1").arg(size), "server", "server_details"); - deleteValue(QString("savePassword%1").arg(size), "server", "server_details"); - deleteValue(QString("password%1").arg(size), "server", "server_details"); - deleteValue(QString("saveName%1").arg(size), "server", "server_details"); - deleteValue(QString("site%1").arg(size), "server", "server_details"); - } } /** @@ -265,23 +330,23 @@ void ServersSettings::removeServer(QString servAddr) */ bool ServersSettings::updateExistingServerWithoutLoss(QString saveName, QString serv, QString port, QString site) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - if (!port.isEmpty()) { - setValue(port, QString("port%1").arg(i), "server", "server_details"); - } - - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(i), "server", "server_details"); - } - - setValue(saveName, QString("saveName%1").arg(i), "server", "server_details"); - - return true; + QList servers = loadServers(); + for (ServerEntry &server : servers) { + if (server.host != serv) { + continue; } + + if (!port.isEmpty()) { + server.port = port; + } + if (!site.isEmpty()) { + server.site = site; + } + server.saveName = saveName; + storeServers(servers); + return true; } + return false; } @@ -293,17 +358,13 @@ bool ServersSettings::updateExistingServer(QString saveName, bool savePassword, QString site) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - setValue(port, QString("port%1").arg(i), "server", "server_details"); - updateServerFields(i, saveName, username, password, savePassword, site); - - return true; - } + const int index = findServerIndex(serv, port); + if (index < 0) { + return false; } - return false; + + updateServerFields(index, saveName, username, password, savePassword, site); + return true; } void ServersSettings::updateServerFields(int index, @@ -313,33 +374,33 @@ void ServersSettings::updateServerFields(int index, bool savePassword, const QString &site) { + QList servers = loadServers(); + if (index < 0 || index >= servers.size()) { + return; + } + + ServerEntry &server = servers[index]; if (!username.isEmpty()) { - setValue(username, QString("username%1").arg(index), "server", "server_details"); + server.username = username; } - if (savePassword && !password.isEmpty()) { - setValue(password, QString("password%1").arg(index), "server", "server_details"); + server.password = password; } else { - setValue(QString(), QString("password%1").arg(index), "server", "server_details"); + server.password.clear(); } - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(index), "server", "server_details"); + server.site = site; } - - setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); - setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); + server.savePassword = savePassword; + server.saveName = saveName; + storeServers(servers); } int ServersSettings::findServerIndex(const QString &host, const QString &port) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString(); - - if (storedHost == host && storedPort == port) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host == host && servers.at(i).port == port) { return i; } } @@ -349,12 +410,9 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c int ServersSettings::findHostIndex(const QString &host) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); - - if (storedHost.compare(host, Qt::CaseInsensitive) == 0) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host.compare(host, Qt::CaseInsensitive) == 0) { return i; } } @@ -364,29 +422,100 @@ int ServersSettings::findHostIndex(const QString &host) const bool ServersSettings::hasUsername(const QString &host, const QString &port) const { - int index = findServerIndex(host, port); - if (index < 0) { - return false; - } - - QString user = getValue(QString("username%1").arg(index), "server", "server_details").toString(); - return !user.isEmpty(); + const auto server = getServer(findServerIndex(host, port)); + return server && !server->username.isEmpty(); } bool ServersSettings::hasCredentials(const QString &host, const QString &port) const { - int index = findServerIndex(host, port); - if (index < 0) { - return false; - } - - bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); - QString password = getValue(QString("password%1").arg(index), "server", "server_details").toString(); - - return save && !password.isEmpty(); + const auto server = getServer(findServerIndex(host, port)); + return server && server->savePassword && !server->password.isEmpty(); } bool ServersSettings::hasLoginData(const QString &host, const QString &port) const { return hasUsername(host, port) && hasCredentials(host, port); } + +QList ServersSettings::loadServers() const +{ + QSettings settings = getSettings(); + settings.beginGroup(SERVER_GROUP); + const QList servers = readServerArray(settings); + settings.endGroup(); + + return servers; +} + +void ServersSettings::storeServers(const QList &servers) +{ + batchWrite([&servers](QSettings &settings) { + settings.beginGroup(SERVER_GROUP); + writeServerArray(settings, servers); + settings.endGroup(); + }); +} + +void ServersSettings::migrateLegacyServers() +{ + { + QSettings settings = getSettings(); + settings.beginGroup(SERVER_GROUP); + // Only builds that predate the array format write this marker. Once it + // is gone there is nothing left to import and no reason to touch the + // file (a sync on every start is a needless open-and-write). + const bool hasLegacy = settings.contains(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP)); + settings.endGroup(); + + if (!hasLegacy) { + return; + } + } + + const QSettings::Status status = batchWrite([](QSettings &settings) { + settings.beginGroup(SERVER_GROUP); + + const int lastLegacyIndex = + settings.value(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP), -1).toInt(); + + QList servers = readServerArray(settings); + + // An older build can write the legacy keys again after the array + // migration, so the array is not automatically complete: append every + // legacy profile the array does not already hold instead of dropping it. + int imported = 0; + const QList legacyServers = readLegacyServers(settings, lastLegacyIndex); + for (const ServersSettings::ServerEntry &entry : legacyServers) { + const bool known = + std::any_of(servers.cbegin(), servers.cend(), [&entry](const ServersSettings::ServerEntry &existing) { + return existing.host == entry.host && existing.port == entry.port; + }); + if (!known) { + servers.append(entry); + ++imported; + } + } + + writeServerArray(settings, servers); + if (imported > 0) { + qCDebug(ServersSettingsLog) << "Migrated" << imported << "server profiles to the array format"; + } + + // The array (freshly written or already present) is authoritative now. + for (int i = 0; i <= lastLegacyIndex; ++i) { + settings.remove(legacyKey("saveName", i)); + settings.remove(legacyKey("server", i)); + settings.remove(legacyKey("port", i)); + settings.remove(legacyKey("username", i)); + settings.remove(legacyKey("password", i)); + settings.remove(legacyKey("savePassword", i)); + settings.remove(legacyKey("site", i)); + } + settings.remove(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP)); + settings.endGroup(); + }); + + if (status != QSettings::NoError) { + qCWarning(ServersSettingsLog) << "Failed to migrate server profiles:" << status; + } +} diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 38bb02876..b403b1cea 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -9,9 +9,11 @@ #include "settings_manager.h" +#include #include #include #include +#include #include class QObject; @@ -27,6 +29,22 @@ class ServersSettings : public SettingsManager friend class SettingsCache; public: + struct ServerEntry + { + QString saveName; + QString host; + QString port; + QString username; + QString password; + bool savePassword = false; + QString site; + }; + + explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr); + + QList getServers() const; + std::optional getServer(int index) const; + int getPreviousHostLogin() const; int getPrevioushostindex(const QString &) const; QStringList getPreviousHostList() const; @@ -88,8 +106,11 @@ public: bool getClearDebugLogStatus(bool abDefaultValue) const; private: - explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr); ServersSettings(const ServersSettings & /*other*/); + + QList loadServers() const; + void storeServers(const QList &servers); + void migrateLegacyServers(); }; #endif // SERVERSSETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp index f98778ca0..7cc1fd7be 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp @@ -193,13 +193,12 @@ QVariant SettingsManager::getValue(const QString &name, return value; } -void SettingsManager::batchWrite(std::function batchWriteFunction) +QSettings::Status SettingsManager::batchWrite(std::function batchWriteFunction) { auto settings = getSettings(); - settings.setAtomicSyncRequired(false); batchWriteFunction(settings); settings.sync(); // single flush - settings.setAtomicSyncRequired(true); + return settings.status(); } /** diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.h b/libcockatrice_settings/libcockatrice/settings/settings_manager.h index 8d3110106..760d095e6 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.h @@ -28,7 +28,9 @@ public: QVariant getValue(const QString &name, const QString &group, const QString &subGroup = QString()) const; QVariant getValue(const QString &name, const QString &group, const QString &subGroup, const QVariant &defaultValue) const; - void batchWrite(std::function batchWriteFunction); + //! \brief Runs \a batchWriteFunction on a temporary QSettings and syncs once. + //! \return the status of the underlying file after the final sync. + QSettings::Status batchWrite(std::function batchWriteFunction); void sync(); diff --git a/tests/settings/CMakeLists.txt b/tests/settings/CMakeLists.txt index 98bed1f4b..9dbda9d6b 100644 --- a/tests/settings/CMakeLists.txt +++ b/tests/settings/CMakeLists.txt @@ -1,5 +1,7 @@ cockatrice_add_test(NAME settings_manager_test SOURCES settings_manager_test.cpp LIBS libcockatrice_settings) +cockatrice_add_test(NAME servers_settings_test SOURCES servers_settings_test.cpp LIBS libcockatrice_settings) + cockatrice_add_test( NAME settings_defaults_test diff --git a/tests/settings/servers_settings_test.cpp b/tests/settings/servers_settings_test.cpp new file mode 100644 index 000000000..5d5953999 --- /dev/null +++ b/tests/settings/servers_settings_test.cpp @@ -0,0 +1,228 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include + +namespace +{ +class ServersSettingsTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + } + + QString settingsFile() const + { + return settingsPath + "servers.ini"; + } + + void writeLegacyServer(int index, + const QString &saveName, + const QString &host, + const QString &port, + const QString &username, + const QString &password, + bool savePassword, + const QString &site) + { + QSettings settings(settingsFile(), QSettings::IniFormat); + const QString prefix = QStringLiteral("server/server_details/"); + settings.setValue(prefix + QStringLiteral("saveName%1").arg(index), saveName); + settings.setValue(prefix + QStringLiteral("server%1").arg(index), host); + settings.setValue(prefix + QStringLiteral("port%1").arg(index), port); + settings.setValue(prefix + QStringLiteral("username%1").arg(index), username); + settings.setValue(prefix + QStringLiteral("password%1").arg(index), password); + settings.setValue(prefix + QStringLiteral("savePassword%1").arg(index), savePassword); + settings.setValue(prefix + QStringLiteral("site%1").arg(index), site); + } +}; + +TEST_F(ServersSettingsTest, AddServerStoresAllFields) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "https://alpha.example"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 1); + EXPECT_EQ(servers.first().saveName, QString("Alpha")); + EXPECT_EQ(servers.first().host, QString("alpha.example")); + EXPECT_EQ(servers.first().port, QString("4748")); + EXPECT_EQ(servers.first().username, QString("alice")); + EXPECT_EQ(servers.first().password, QString("secret")); + EXPECT_TRUE(servers.first().savePassword); + EXPECT_EQ(servers.first().site, QString("https://alpha.example")); +} + +TEST_F(ServersSettingsTest, AddExistingHostAndPortUpdatesInsteadOfDuplicating) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a"); + settings.addNewServer("Renamed", "alpha.example", "4748", "bob", "new-secret", true, "site-b"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 1); + EXPECT_EQ(servers.first().saveName, QString("Renamed")); + EXPECT_EQ(servers.first().username, QString("bob")); + EXPECT_EQ(servers.first().password, QString("new-secret")); + EXPECT_EQ(servers.first().site, QString("site-b")); +} + +TEST_F(ServersSettingsTest, RemoveServerRemovesOnlyMatchingHostAndKeepsOrder) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, ""); + settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "c", true, ""); + + settings.removeServer("beta.example"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(1).saveName, QString("Gamma")); +} + +TEST_F(ServersSettingsTest, RemovingLastServerDropsItsStaleArraySlot) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, ""); + settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "gamma-secret", true, ""); + + settings.removeServer("gamma.example"); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/servers/3/password")); + EXPECT_FALSE(raw.contains("server/server_details/servers/3/saveName")); + ASSERT_EQ(settings.getServers().size(), 2); +} + +TEST_F(ServersSettingsTest, UpdateWithoutLossKeepsCredentials) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + + EXPECT_TRUE(settings.updateExistingServerWithoutLoss("Renamed", "alpha.example", "1234", "site")); + + const auto server = settings.getServer(settings.findServerIndex("alpha.example", "1234")); + ASSERT_TRUE(server.has_value()); + EXPECT_EQ(server->saveName, QString("Renamed")); + EXPECT_EQ(server->username, QString("alice")); + EXPECT_EQ(server->password, QString("secret")); + EXPECT_TRUE(server->savePassword); + EXPECT_EQ(server->site, QString("site")); +} + +TEST_F(ServersSettingsTest, SetServerPasswordUpdatesOnlyTheNamedProfile) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "old", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "keep", true, ""); + + settings.setServerPassword("Beta", "new"); + + EXPECT_EQ(settings.getServer(0)->password, QString("old")); + EXPECT_EQ(settings.getServer(1)->password, QString("new")); +} + +TEST_F(ServersSettingsTest, LegacyProfilesAreMigrated) +{ + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 2); + } + writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a"); + writeLegacyServer(2, "Beta", "beta.example", "4748", "bob", "other", false, "site-b"); + + ServersSettings settings(settingsPath, nullptr); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(0).host, QString("alpha.example")); + EXPECT_TRUE(servers.at(0).savePassword); + EXPECT_EQ(servers.at(1).saveName, QString("Beta")); + EXPECT_FALSE(servers.at(1).savePassword); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/totalServers")); + EXPECT_FALSE(raw.contains("server/server_details/saveName1")); + EXPECT_FALSE(raw.contains("server/server_details/server1")); +} + +TEST_F(ServersSettingsTest, MigrationRunsOnce) +{ + writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 1); + } + + { + ServersSettings first(settingsPath, nullptr); + ASSERT_EQ(first.getServers().size(), 1); + } + + ServersSettings second(settingsPath, nullptr); + ASSERT_EQ(second.getServers().size(), 1); +} + +TEST_F(ServersSettingsTest, LegacyProfileAddedAfterMigrationIsMerged) +{ + { + ServersSettings first(settingsPath, nullptr); + first.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + } + + // An older build writes its own profile back in the legacy format after the + // array migration already happened. + writeLegacyServer(1, "Beta", "beta.example", "4748", "bob", "other", true, "site-b"); + // A duplicate of a profile the array already holds must not be added twice. + writeLegacyServer(2, "Alpha duplicate", "alpha.example", "4748", "eve", "dupe", true, ""); + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 2); + } + + ServersSettings settings(settingsPath, nullptr); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(0).password, QString("secret")); + EXPECT_EQ(servers.at(1).saveName, QString("Beta")); + EXPECT_EQ(servers.at(1).host, QString("beta.example")); + EXPECT_EQ(servers.at(1).username, QString("bob")); + EXPECT_EQ(servers.at(1).password, QString("other")); + EXPECT_TRUE(servers.at(1).savePassword); + EXPECT_EQ(servers.at(1).site, QString("site-b")); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/totalServers")); + EXPECT_FALSE(raw.contains("server/server_details/saveName1")); + EXPECT_FALSE(raw.contains("server/server_details/server2")); + + // The merge is persisted, so a further start adds nothing. + ServersSettings again(settingsPath, nullptr); + ASSERT_EQ(again.getServers().size(), 2); +} + +TEST_F(ServersSettingsTest, OutOfRangeIndexReturnsNullopt) +{ + ServersSettings settings(settingsPath, nullptr); + EXPECT_FALSE(settings.getServer(-1).has_value()); + EXPECT_FALSE(settings.getServer(0).has_value()); +} +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3cf8bdb00e124e272ce3caa5f372d967dd758630 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:35:41 +0100 Subject: [PATCH 183/247] [Tests] Add a headless game logic regression suite (#7702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Tests] Add headless game logic regression suite Cover CardState, CardZoneLogic and the Command_MoveCard protobufs PlayerActions emits, against libcockatrice_game with a recording AbstractClient and no graphics. The old game_smoke_test cases are migrated into the new suite. * [Tests] Cover game event application through GameEventHandler Feeds GameEventContainers through the real dispatcher and asserts the resulting zone and card state: move (including face down), the set-card-attr attributes plus the all-cards and does-not-untap branches, card counters, attach/detach, zone reveal properties, and drawing named cards from the deck. * [Tests] Replay a synthetic game state through the dispatcher Builds a GameReplay in code, feeds its containers through the same GameEventHandler path the client uses, and asserts the materialized players, zones, cards and attachments plus event ordering for move, attributes and counters. A small headless zone view stands in for the graphics side where processPlayerInfo asks for card items. * [Tests] Cover card flip, destroy, token and turn events Adds the remaining state-mutating event handlers: flip (face down keeps the ref, face up adopts the printing), destroy (removes the card and un-attaches its attached cards), create token (database P/T unless provided or face down) and player properties changed under the concede/unconcede contexts, plus active player and phase. * [Tests] Cover player lifecycle and counter events Adds the player-level events that mutate session state: leave removes the player from the manager, kicked emits gameClosed, game closed flips the closed flag and clears the started flag, host changed moves the host role to another player, and create/set/delete update the player counters with unknown ids ignored. * [Tests] Drive the zone view request through addCard The test emitted createCardRequested itself and then asserted it was emitted. Registering a ZoneViewZoneLogic on the zone and adding a card exercises the real prepareAddCard -> signal path, so the assertion now fails if addCard stops asking the graphics side to build the card. * [Tests] Skip the card database when listing tests gtest_discover_tests runs the binary with --gtest_list_tests during the build. That process built the card database and replaced its default path provider, leaving allocations that the sanitizer leg's default LeakSanitizer check reports at exit, so discovery failed on Ubuntu 26.04. No test runs in list mode, so return before the database setup. * [Tests] Fix fixture comment nit Wraps the RecordingClient::sent member name in backticks in the comment. --------- Co-authored-by: Lukas Brübach --- tests/game/CMakeLists.txt | 42 +- tests/game/card_state_test.cpp | 110 +++++ tests/game/card_zone_logic_test.cpp | 107 +++++ tests/game/data/cards.xml | 64 +++ tests/game/data/spoilers.xml | 4 + tests/game/data/tokens.xml | 21 + tests/game/game_event_handler_test.cpp | 566 +++++++++++++++++++++++++ tests/game/game_smoke_test.cpp | 106 ----- tests/game/game_test_fixture.h | 129 ++++++ tests/game/game_test_main.cpp | 126 ++++++ tests/game/player_actions_test.cpp | 119 ++++++ tests/game/replay_test.cpp | 349 +++++++++++++++ 12 files changed, 1611 insertions(+), 132 deletions(-) create mode 100644 tests/game/card_state_test.cpp create mode 100644 tests/game/card_zone_logic_test.cpp create mode 100644 tests/game/data/cards.xml create mode 100644 tests/game/data/spoilers.xml create mode 100644 tests/game/data/tokens.xml create mode 100644 tests/game/game_event_handler_test.cpp delete mode 100644 tests/game/game_smoke_test.cpp create mode 100644 tests/game/game_test_fixture.h create mode 100644 tests/game/game_test_main.cpp create mode 100644 tests/game/player_actions_test.cpp create mode 100644 tests/game/replay_test.cpp diff --git a/tests/game/CMakeLists.txt b/tests/game/CMakeLists.txt index 4a7492c71..42c935c8f 100644 --- a/tests/game/CMakeLists.txt +++ b/tests/game/CMakeLists.txt @@ -1,28 +1,18 @@ -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 +cockatrice_add_test( + NAME + game_logic_test + SOURCES + game_test_main.cpp + card_state_test.cpp + card_zone_logic_test.cpp + game_event_handler_test.cpp + player_actions_test.cpp + replay_test.cpp ${VERSION_STRING_CPP} - game_smoke_test.cpp + LIBS + libcockatrice_game + INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR} + DEFINITIONS + GAME_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data" ) - -# 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/card_state_test.cpp b/tests/game/card_state_test.cpp new file mode 100644 index 000000000..2f2c43bf4 --- /dev/null +++ b/tests/game/card_state_test.cpp @@ -0,0 +1,110 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent) +{ + CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1); + CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2); + + guest.setAttachedTo(&host); + host.addAttachedCard(&guest); + + EXPECT_EQ(guest.getAttachedTo(), &host); + ASSERT_EQ(host.getAttachedCards().size(), 1); + EXPECT_EQ(host.getAttachedCards().first(), &guest); + + host.removeAttachedCard(&guest); + guest.setAttachedTo(nullptr); + + EXPECT_TRUE(host.getAttachedCards().isEmpty()); + EXPECT_EQ(guest.getAttachedTo(), nullptr); +} + +TEST(CardStateTest, CountersAddUpdateAndClear) +{ + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1); + int changes = 0; + QObject::connect(&card, &CardState::countersChanged, [&changes]() { ++changes; }); + + card.insertCounter(1, 2); + EXPECT_EQ(card.getCounters().value(1), 2); + + card.setCounter(1, 3); + EXPECT_EQ(card.getCounters().value(1), 3); + + card.setCounter(1, 0); + EXPECT_FALSE(card.getCounters().contains(1)); + + card.setCounter(2, 1); + card.clearCounters(); + EXPECT_TRUE(card.getCounters().isEmpty()); + + EXPECT_EQ(changes, 5); +} + +TEST(CardStateTest, TapFlipAttackingAndReset) +{ + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1); + card.setTapped(true); + card.setFaceDown(true); + card.setAttacking(true); + card.setAnnotation(QStringLiteral("note")); + card.setPT(QStringLiteral("4/4")); + card.setDoesntUntap(true); + card.setDestroyOnZoneChange(true); + card.setColor(QStringLiteral("red")); + card.insertCounter(1, 2); + + card.resetState(true); + + EXPECT_FALSE(card.getTapped()); + EXPECT_FALSE(card.getAttacking()); + EXPECT_TRUE(card.getCounters().isEmpty()); + EXPECT_TRUE(card.getPT().isEmpty()); + EXPECT_FALSE(card.getDoesntUntap()); + EXPECT_EQ(card.getAnnotation(), QStringLiteral("note")); + // resetState() deliberately leaves these three alone. + EXPECT_TRUE(card.getFaceDown()); + EXPECT_EQ(card.getColor(), QStringLiteral("red")); + EXPECT_TRUE(card.getDestroyOnZoneChange()); + + card.setAnnotation(QStringLiteral("other")); + card.resetState(false); + EXPECT_TRUE(card.getAnnotation().isEmpty()); +} + +TEST(CardStateTest, ParsePT) +{ + const QVariantList plain = CardState::parsePT(QStringLiteral("3/3")); + ASSERT_EQ(plain.size(), 2); + EXPECT_EQ(plain.at(0).toString(), QStringLiteral("3")); + EXPECT_EQ(plain.at(1).toString(), QStringLiteral("3")); + + const QVariantList negative = CardState::parsePT(QStringLiteral("-1/2")); + ASSERT_EQ(negative.size(), 2); + EXPECT_EQ(negative.at(0).typeId(), QMetaType::Int); + EXPECT_EQ(negative.at(0).toInt(), -1); + EXPECT_EQ(negative.at(1).toString(), QStringLiteral("2")); + + const QVariantList star = CardState::parsePT(QStringLiteral("*")); + ASSERT_EQ(star.size(), 1); + EXPECT_EQ(star.at(0).toString(), QStringLiteral("*")); + + EXPECT_TRUE(CardState::parsePT(QString()).isEmpty()); +} diff --git a/tests/game/card_zone_logic_test.cpp b/tests/game/card_zone_logic_test.cpp new file mode 100644 index 000000000..7f0c39b47 --- /dev/null +++ b/tests/game/card_zone_logic_test.cpp @@ -0,0 +1,107 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + + QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded); + QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged); + + zone.addCard(&card, false, 0); + + ASSERT_EQ(zone.getCards().size(), 1); + EXPECT_EQ(zone.getCards().first(), &card); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(cardAddedSpy.count(), 1); + EXPECT_GE(cardCountSpy.count(), 1); + + EXPECT_EQ(zone.takeCard(0, 7), &card); + EXPECT_TRUE(zone.getCards().isEmpty()); + EXPECT_EQ(card.getId(), 7); +} + +TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards) +{ + HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true); + // The graphics side owns the view logic and registers it on the zone it + // shows; adding a card then goes through prepareAddCard and the request + // signal instead of the test emitting the signal itself. + ZoneViewZoneLogic view(nullptr, &zone, 1, true, false, false); + zone.getViews().append(&view); + + QSignalSpy requestSpy(&view, &CardZoneLogic::createCardRequested); + + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + zone.addCard(&card, true, 0, 0); + + ASSERT_EQ(requestSpy.count(), 1); + const QList arguments = requestSpy.takeFirst(); + const ServerInfo_Card cardInfo = arguments.at(0).value(); + EXPECT_EQ(cardInfo.id(), 7); + EXPECT_EQ(cardInfo.name(), "Lightning Bolt"); + EXPECT_TRUE(arguments.at(1).toBool()); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(zone.getCards().size(), 1); +} + +TEST(CardZoneLogicTest, MoveBetweenZonesUpdatesCardZone) +{ + HandZoneLogic first(nullptr, QStringLiteral("hand"), false, false, true); + HandZoneLogic second(nullptr, QStringLiteral("grave"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + + first.addCard(&card, false, 0); + ASSERT_EQ(first.getCards().size(), 1); + + CardState *taken = first.takeCard(0, 7); + ASSERT_EQ(taken, &card); + EXPECT_TRUE(first.getCards().isEmpty()); + + second.addCard(taken, false, 0); + EXPECT_EQ(second.getCards().size(), 1); + EXPECT_EQ(card.getZone(), &second); +} + +TEST(CardZoneLogicTest, GetCardFindsById) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState first(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + CardState second(nullptr, nullptr, cardRefFor(QStringLiteral("Dog")), 8); + + zone.addCard(&first, false, 0); + zone.addCard(&second, false, 0); + + EXPECT_EQ(zone.getCard(7), &first); + EXPECT_EQ(zone.getCard(8), &second); + EXPECT_EQ(zone.getCard(99), nullptr); +} + +TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + QSignalSpy reorganizeSpy(&zone, &CardZoneLogic::reorganizeCards); + + zone.addCard(&card, true, 0); + + EXPECT_EQ(reorganizeSpy.count(), 1); +} diff --git a/tests/game/data/cards.xml b/tests/game/data/cards.xml new file mode 100644 index 000000000..e315dbf48 --- /dev/null +++ b/tests/game/data/cards.xml @@ -0,0 +1,64 @@ + + + + + Cat + CAT + 0 + Meow! + + 111 + G + 2G + 2 + Creature — Cat + Creature + 3/3 + + + + Grizzly Bears + LEA + 0 + 1 + + + 112 + G + 1G + 2 + Creature — Bear + Creature + 2/2 + + + + Lightning Bolt + LEA + 3 + Lightning Bolt deals 3 damage to any target. + + 113 + R + R + 1 + Instant + Instant + + + + Forest + LEA + 2 + (Add G.) + + 114 + G + + 0 + Land + Land + + + + diff --git a/tests/game/data/spoilers.xml b/tests/game/data/spoilers.xml new file mode 100644 index 000000000..414ae10d4 --- /dev/null +++ b/tests/game/data/spoilers.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/game/data/tokens.xml b/tests/game/data/tokens.xml new file mode 100644 index 000000000..30edbb24c --- /dev/null +++ b/tests/game/data/tokens.xml @@ -0,0 +1,21 @@ + + + + + Kitten + CAT + 0 + + 1 + + 211 + G + + + Token + Token + 1/1 + + + + diff --git a/tests/game/game_event_handler_test.cpp b/tests/game/game_event_handler_test.cpp new file mode 100644 index 000000000..2db6a1660 --- /dev/null +++ b/tests/game/game_event_handler_test.cpp @@ -0,0 +1,566 @@ +#include "game_test_fixture.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * Wraps one player-scoped event in the container the server would send and + * pushes it through the real dispatcher, so the test exercises the same + * GameEventHandler -> PlayerEventHandler path as a live client. + */ +template +void sendGameEvent(GameHarness &harness, + int playerId, + const EventType &message, + const GameEventContext *context = nullptr) +{ + GameEventContainer container; + container.set_game_id(42); + if (context != nullptr) { + container.mutable_context()->CopyFrom(*context); + } + + GameEvent *event = container.add_event_list(); + event->set_player_id(playerId); + event->MutableExtension(EventType::ext)->CopyFrom(message); + + harness.game()->getGameEventHandler()->processGameEventContainer(container, harness.client(), + EventProcessingOptions()); +} + +Event_SetCardAttr cardAttrEvent(const char *zoneName, int cardId, CardAttribute attribute, const char *value) +{ + Event_SetCardAttr event; + event.set_zone_name(zoneName); + event.set_card_id(cardId); + event.set_attribute(attribute); + event.set_attr_value(value); + return event; +} + +} // namespace + +TEST(GameEventHandlerTest, MoveCardMovesBetweenZones) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_MoveCard event; + event.set_card_id(7); + event.set_start_player_id(1); + event.set_start_zone(ZoneNames::HAND); + event.set_position(0); + event.set_target_player_id(1); + event.set_target_zone(ZoneNames::TABLE); + event.set_x(0); + event.set_y(TableZoneLogic::tableRowToGridY(0)); + event.set_new_card_id(7); + event.set_face_down(false); + + sendGameEvent(harness, 1, event); + + EXPECT_TRUE(harness.zone(ZoneNames::HAND)->getCards().isEmpty()); + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_EQ(table->getCards().size(), 1); + EXPECT_EQ(table->getCards().first(), cat); + EXPECT_EQ(cat->getZone(), table); + EXPECT_EQ(cat->getId(), 7); + EXPECT_EQ(cat->getCardRef().name, QStringLiteral("Cat")); + EXPECT_FALSE(cat->getFaceDown()); + EXPECT_EQ(cat->getPT(), QStringLiteral("3/3")); + EXPECT_EQ(cat->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0))); +} + +TEST(GameEventHandlerTest, MoveCardFaceDownKeepsTheCardHidden) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_MoveCard event; + event.set_card_id(7); + event.set_start_player_id(1); + event.set_start_zone(ZoneNames::HAND); + event.set_position(0); + event.set_target_player_id(1); + event.set_target_zone(ZoneNames::TABLE); + event.set_x(1); + event.set_y(TableZoneLogic::tableRowToGridY(2)); + event.set_new_card_id(7); + event.set_face_down(true); + + sendGameEvent(harness, 1, event); + + ASSERT_EQ(harness.zone(ZoneNames::TABLE)->getCards().size(), 1); + EXPECT_TRUE(cat->getFaceDown()); + // A face-down card keeps whatever P/T it had instead of picking up the + // printed one from the card database. + EXPECT_TRUE(cat->getPT().isEmpty()); +} + +TEST(GameEventHandlerTest, SetCardAttrUpdatesSingleCard) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAttacking, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrPT, "5/5")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAnnotation, "note")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrColor, "red")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrDoesntUntap, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrFaceDown, "1")); + + EXPECT_TRUE(cat->getTapped()); + EXPECT_TRUE(cat->getAttacking()); + EXPECT_EQ(cat->getPT(), QStringLiteral("5/5")); + EXPECT_EQ(cat->getAnnotation(), QStringLiteral("note")); + EXPECT_EQ(cat->getColor(), QStringLiteral("red")); + EXPECT_TRUE(cat->getDoesntUntap()); + EXPECT_TRUE(cat->getFaceDown()); +} + +TEST(GameEventHandlerTest, SetCardAttrWithoutCardIdTouchesEveryCardInZone) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(cat, nullptr); + ASSERT_NE(bears, nullptr); + + Event_SetCardAttr event; + event.set_zone_name(ZoneNames::TABLE); + event.set_attribute(AttrAnnotation); + event.set_attr_value("marked"); + + sendGameEvent(harness, 1, event); + + EXPECT_EQ(cat->getAnnotation(), QStringLiteral("marked")); + EXPECT_EQ(bears->getAnnotation(), QStringLiteral("marked")); +} + +TEST(GameEventHandlerTest, UntapAllLeavesDoesntUntapCardsTapped) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(cat, nullptr); + ASSERT_NE(bears, nullptr); + cat->setDoesntUntap(true); + + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 9, AttrTapped, "1")); + + Event_SetCardAttr untapAll; + untapAll.set_zone_name(ZoneNames::TABLE); + untapAll.set_attribute(AttrTapped); + untapAll.set_attr_value("0"); + + sendGameEvent(harness, 1, untapAll); + + EXPECT_TRUE(cat->getTapped()); + EXPECT_FALSE(bears->getTapped()); +} + +TEST(GameEventHandlerTest, SetCardCounterAddsAndRemoves) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_SetCardCounter add; + add.set_zone_name(ZoneNames::HAND); + add.set_card_id(7); + add.set_counter_id(1); + add.set_counter_value(2); + + sendGameEvent(harness, 1, add); + + EXPECT_EQ(cat->getCounters().value(1), 2); + + Event_SetCardCounter remove = add; + remove.set_counter_value(0); + + sendGameEvent(harness, 1, remove); + + EXPECT_FALSE(cat->getCounters().contains(1)); +} + +TEST(GameEventHandlerTest, AttachCardLinksBothEndsAndUnattachClearsThem) +{ + GameHarness harness; + CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(host, nullptr); + ASSERT_NE(guest, nullptr); + + Event_AttachCard attach; + attach.set_start_zone(ZoneNames::TABLE); + attach.set_card_id(9); + attach.set_target_player_id(1); + attach.set_target_zone(ZoneNames::TABLE); + attach.set_target_card_id(7); + + sendGameEvent(harness, 1, attach); + + EXPECT_EQ(guest->getAttachedTo(), host); + ASSERT_EQ(host->getAttachedCards().size(), 1); + EXPECT_EQ(host->getAttachedCards().first(), guest); + + Event_AttachCard detach; + detach.set_start_zone(ZoneNames::TABLE); + detach.set_card_id(9); + + sendGameEvent(harness, 1, detach); + + EXPECT_EQ(guest->getAttachedTo(), nullptr); + EXPECT_TRUE(host->getAttachedCards().isEmpty()); +} + +TEST(GameEventHandlerTest, ChangeZonePropertiesUpdatesReveal) +{ + GameHarness harness; + CardZoneLogic *deck = harness.zone(ZoneNames::DECK); + ASSERT_NE(deck, nullptr); + + Event_ChangeZoneProperties reveal; + reveal.set_zone_name(ZoneNames::DECK); + reveal.set_always_reveal_top_card(true); + + sendGameEvent(harness, 1, reveal); + EXPECT_TRUE(deck->getAlwaysRevealTopCard()); + + reveal.set_always_reveal_top_card(false); + sendGameEvent(harness, 1, reveal); + EXPECT_FALSE(deck->getAlwaysRevealTopCard()); +} + +TEST(GameEventHandlerTest, DrawCardsMovesNamedCardsFromDeckToHand) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::DECK, QStringLiteral("Cat"), 7), nullptr); + + Event_DrawCards draw; + draw.set_number(1); + ServerInfo_Card *cardInfo = draw.add_cards(); + cardInfo->set_id(7); + cardInfo->set_name("Cat"); + + sendGameEvent(harness, 1, draw); + + EXPECT_TRUE(harness.zone(ZoneNames::DECK)->getCards().isEmpty()); + ASSERT_EQ(harness.zone(ZoneNames::HAND)->getCards().size(), 1); + CardState *drawn = harness.zone(ZoneNames::HAND)->getCards().first(); + EXPECT_EQ(drawn->getId(), 7); + EXPECT_EQ(drawn->getName(), QStringLiteral("Cat")); +} + +TEST(GameEventHandlerTest, FlipCardHidesAndRevealsTheCard) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_FlipCard flip; + flip.set_zone_name(ZoneNames::TABLE); + flip.set_card_id(7); + flip.set_face_down(true); + + sendGameEvent(harness, 1, flip); + + EXPECT_TRUE(cat->getFaceDown()); + // Flipping face down does not touch the card reference: the client must not + // learn (or overwrite) the name of a card it is not allowed to see. + EXPECT_EQ(cat->getName(), QStringLiteral("Cat")); + + // Turning it face up carries the real printing, so the state picks it up. + flip.set_face_down(false); + flip.set_card_name("Grizzly Bears"); + + sendGameEvent(harness, 1, flip); + + EXPECT_FALSE(cat->getFaceDown()); + EXPECT_EQ(cat->getName(), QStringLiteral("Grizzly Bears")); +} + +TEST(GameEventHandlerTest, DestroyCardRemovesItAndDetachesAttachedCards) +{ + GameHarness harness; + CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(host, nullptr); + ASSERT_NE(guest, nullptr); + + guest->setAttachedTo(host); + ASSERT_EQ(guest->getAttachedTo(), host); + ASSERT_EQ(host->getAttachedCards().size(), 1); + + Event_DestroyCard destroy; + destroy.set_zone_name(ZoneNames::TABLE); + destroy.set_card_id(7); + + sendGameEvent(harness, 1, destroy); + + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_EQ(table->getCards().size(), 1); + EXPECT_EQ(table->getCards().first(), guest); + EXPECT_FALSE(table->getCards().contains(host)); + + // The attached card survives the host and is left un-attached, ready to be + // laid out again by the zone. + EXPECT_EQ(guest->getAttachedTo(), nullptr); + EXPECT_TRUE(host->getAttachedCards().isEmpty()); + EXPECT_EQ(guest->getZone(), table); +} + +TEST(GameEventHandlerTest, CreateTokenUsesDatabasePTUnlessFaceDown) +{ + GameHarness harness; + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_NE(table, nullptr); + + QList created; + QObject::connect(table, &CardZoneLogic::createCardRequested, table, + [&created](const ServerInfo_Card &cardInfo, bool) { created.append(cardInfo); }); + + Event_CreateToken token; + token.set_zone_name(ZoneNames::TABLE); + token.set_card_id(11); + token.set_card_name("Kitten"); + token.set_x(2); + token.set_y(1); + token.set_color("green"); + token.set_annotation("a kitten"); + token.set_destroy_on_zone_change(true); + + sendGameEvent(harness, 1, token); + + ASSERT_EQ(created.size(), 1); + // The event carries no P/T, so the token takes the printed one. + EXPECT_EQ(created.first().pt(), "1/1"); + EXPECT_EQ(created.first().name(), "Kitten"); + EXPECT_EQ(created.first().x(), 2); + EXPECT_EQ(created.first().y(), 1); + EXPECT_EQ(created.first().color(), "green"); + EXPECT_EQ(created.first().annotation(), "a kitten"); + EXPECT_TRUE(created.first().destroy_on_zone_change()); + EXPECT_FALSE(created.first().face_down()); + + // An explicit P/T always wins over the database. + Event_CreateToken explicitPt = token; + explicitPt.set_card_id(12); + explicitPt.set_pt("3/3"); + + sendGameEvent(harness, 1, explicitPt); + + ASSERT_EQ(created.size(), 2); + EXPECT_EQ(created.last().pt(), "3/3"); + + // A face-down token must not leak the printed P/T. + Event_CreateToken faceDown = token; + faceDown.set_card_id(13); + faceDown.set_face_down(true); + + sendGameEvent(harness, 1, faceDown); + + ASSERT_EQ(created.size(), 3); + EXPECT_TRUE(created.last().face_down()); + EXPECT_TRUE(created.last().pt().empty()); +} + +TEST(GameEventHandlerTest, ConcedeAndUnconcedeUpdatePlayerState) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr); + EXPECT_FALSE(harness.player()->getConceded()); + + Event_PlayerPropertiesChanged properties; + properties.mutable_player_properties()->set_player_id(1); + properties.mutable_player_properties()->set_conceded(true); + + GameEventContext concedeContext; + concedeContext.MutableExtension(Context_Concede::ext); + + sendGameEvent(harness, 1, properties, &concedeContext); + + EXPECT_TRUE(harness.player()->getConceded()); + // Conceding tears the board down for that player. + EXPECT_TRUE(harness.zone(ZoneNames::TABLE)->getCards().isEmpty()); + + Event_PlayerPropertiesChanged back = properties; + back.mutable_player_properties()->set_conceded(false); + + GameEventContext unconcedeContext; + unconcedeContext.MutableExtension(Context_Unconcede::ext); + + sendGameEvent(harness, 1, back, &unconcedeContext); + + EXPECT_FALSE(harness.player()->getConceded()); +} + +TEST(GameEventHandlerTest, SetActivePlayerAndPhaseUpdateGameState) +{ + GameHarness harness; + GameState *state = harness.game()->getGameState(); + ASSERT_NE(state, nullptr); + EXPECT_EQ(state->getActivePlayer(), -1); + EXPECT_EQ(state->getCurrentPhase(), -1); + + Event_SetActivePlayer setPlayer; + setPlayer.set_active_player_id(1); + + sendGameEvent(harness, 1, setPlayer); + + EXPECT_EQ(state->getActivePlayer(), 1); + + Event_SetActivePhase setPhase; + setPhase.set_phase(3); + + sendGameEvent(harness, 1, setPhase); + + EXPECT_EQ(state->getCurrentPhase(), 3); +} + +TEST(GameEventHandlerTest, CreateSetAndDeletePlayerCounter) +{ + GameHarness harness; + + ServerInfo_Counter counter; + counter.set_id(1); + counter.set_name("life"); + counter.set_radius(1); + counter.set_count(20); + + Event_CreateCounter create; + create.mutable_counter_info()->CopyFrom(counter); + + sendGameEvent(harness, 1, create); + + CounterState *state = harness.player()->getCounters().value(1); + ASSERT_NE(state, nullptr); + EXPECT_EQ(state->getName(), QStringLiteral("life")); + EXPECT_EQ(state->getValue(), 20); + EXPECT_EQ(harness.player()->getLifeCounter(), state); + + Event_SetCounter set; + set.set_counter_id(1); + set.set_value(15); + + sendGameEvent(harness, 1, set); + + EXPECT_EQ(state->getValue(), 15); + + // Updating a counter that does not exist is a no-op, not a crash. + Event_SetCounter unknown = set; + unknown.set_counter_id(99); + sendGameEvent(harness, 1, unknown); + + EXPECT_FALSE(harness.player()->getCounters().contains(99)); + + Event_DelCounter remove; + remove.set_counter_id(1); + + sendGameEvent(harness, 1, remove); + + EXPECT_FALSE(harness.player()->getCounters().contains(1)); + EXPECT_EQ(harness.player()->getLifeCounter(), nullptr); +} + +TEST(GameEventHandlerTest, LeaveRemovesThePlayer) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr); + ASSERT_NE(harness.game()->getPlayerManager()->getPlayer(1), nullptr); + + Event_Leave leave; + leave.set_reason(Event_Leave::USER_LEFT); + + sendGameEvent(harness, 1, leave); + + EXPECT_EQ(harness.game()->getPlayerManager()->getPlayer(1), nullptr); + EXPECT_TRUE(harness.game()->getPlayerManager()->getPlayers().isEmpty()); +} + +TEST(GameEventHandlerTest, KickedSignalsGameClosed) +{ + GameHarness harness; + + bool closed = false; + QObject::connect(harness.game()->getGameEventHandler(), &GameEventHandler::gameClosed, + [&closed] { closed = true; }); + + sendGameEvent(harness, 1, Event_Kicked()); + + EXPECT_TRUE(closed); +} + +TEST(GameEventHandlerTest, GameClosedMarksTheSessionClosed) +{ + GameHarness harness; + ASSERT_FALSE(harness.game()->getGameState()->isGameClosed()); + + Event_GameStateChanged started; + started.set_game_started(true); + sendGameEvent(harness, 1, started); + ASSERT_TRUE(harness.game()->getGameMetaInfo()->started()); + + sendGameEvent(harness, 1, Event_GameClosed()); + + EXPECT_TRUE(harness.game()->getGameState()->isGameClosed()); + EXPECT_FALSE(harness.game()->getGameMetaInfo()->started()); +} + +TEST(GameEventHandlerTest, GameHostChangedMovesTheHostRole) +{ + GameHarness harness; + + ServerInfo_User bob; + bob.set_name("Bob"); + bob.set_user_level(ServerInfo_User::IsUser); + ASSERT_NE(harness.game()->getPlayerManager()->addPlayer(2, bob), nullptr); + + sendGameEvent(harness, 2, Event_GameHostChanged()); + + EXPECT_EQ(harness.game()->getGameState()->getHostId(), 2); +} diff --git a/tests/game/game_smoke_test.cpp b/tests/game/game_smoke_test.cpp deleted file mode 100644 index 356d544bf..000000000 --- a/tests/game/game_smoke_test.cpp +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @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/game/game_test_fixture.h b/tests/game/game_test_fixture.h new file mode 100644 index 000000000..e4703c917 --- /dev/null +++ b/tests/game/game_test_fixture.h @@ -0,0 +1,129 @@ +/** + * @file game_test_fixture.h + * + * Fixtures for tests that need a complete game/player/zone graph without the + * client, a server or any graphics object. Commands are captured on a recording + * AbstractClient, which is the same seam the real client uses. + */ + +#ifndef COCKATRICE_TESTS_GAME_TEST_FIXTURE_H +#define COCKATRICE_TESTS_GAME_TEST_FIXTURE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Captures every command the game logic sends, instead of writing it to a socket. + * + * AbstractClient::sendCommand uses a direct signal/slot connection in the same + * thread, so a command is in `sent` as soon as sendCommand returns. + */ +class RecordingClient : public AbstractClient +{ +public: + QList sent; + +protected: + void sendCommandContainer(const CommandContainer &cont) override + { + sent.append(cont); + } +}; + +/** + * Owns a local, single-client Game with one local player, plus the cards added + * to its zones. + */ +class GameHarness +{ +public: + explicit GameHarness(int playerId = 1) : playerId_(playerId), client_(std::make_unique()) + { + Event_GameJoined event; + event.set_host_id(playerId_); + event.set_player_id(playerId_); + event.mutable_game_info()->set_game_id(42); + event.mutable_game_info()->set_started(true); + + QList clients{client_.get()}; + QMap roomGameTypes; + game_ = std::make_unique(nullptr, true, clients, event, roomGameTypes); + + ServerInfo_User user; + user.set_name("Alice"); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + game_->getPlayerManager()->addPlayer(playerId_, user); + } + + ~GameHarness() + { + qDeleteAll(ownedCards_); + ownedCards_.clear(); + } + + GameHarness(const GameHarness &) = delete; + GameHarness &operator=(const GameHarness &) = delete; + + [[nodiscard]] Game *game() const + { + return game_.get(); + } + + [[nodiscard]] RecordingClient *client() const + { + return client_.get(); + } + + [[nodiscard]] PlayerLogic *player() const + { + return game_->getPlayerManager()->getPlayer(playerId_); + } + + [[nodiscard]] CardZoneLogic *zone(const QString &zoneName) const + { + return player()->getZones().value(zoneName); + } + + CardState *addCardToZone(const QString &zoneName, const QString &cardName, int cardId) + { + CardZoneLogic *targetZone = zone(zoneName); + if (!targetZone) { + return nullptr; + } + + auto *card = new CardState(nullptr, targetZone, CardRef{cardName, QString()}, cardId); + // The constructor only stores the ref; this is what fills in the card + // data (table row, P/T, ...) the actions read. + card->refreshCardInfo(); + targetZone->addCard(card, false, 0); + ownedCards_.append(card); + return card; + } + + [[nodiscard]] const CommandContainer &lastCommand() const + { + return client_->sent.last(); + } + +private: + int playerId_; + std::unique_ptr client_; + std::unique_ptr game_; + QList ownedCards_; +}; + +#endif // COCKATRICE_TESTS_GAME_TEST_FIXTURE_H diff --git a/tests/game/game_test_main.cpp b/tests/game/game_test_main.cpp new file mode 100644 index 000000000..47b9a6b8e --- /dev/null +++ b/tests/game/game_test_main.cpp @@ -0,0 +1,126 @@ +/** + * @file game_test_main.cpp + * + * Test entry point for the headless game-logic suite. The game library has no + * graphics dependency, so a QCoreApplication is enough; the only process-wide + * setup left is the card database, which the game logic still reaches through + * CardDatabaseManager and which therefore has to exist before the first zone is + * constructed. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class GameTestPathProvider : public ICardDatabasePathProvider +{ +public: + explicit GameTestPathProvider(QString baseDir) : baseDir_(std::move(baseDir)) + { + } + + QString getCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/cards.xml"); + } + + QString getCustomCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/customsets/"); + } + + QString getTokenDatabasePath() const override + { + return baseDir_ + QStringLiteral("/tokens.xml"); + } + + QString getSpoilerCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/spoilers.xml"); + } + +private: + QString baseDir_; +}; + +bool copyFixture(const QString &fileName, const QString &targetDir) +{ + const QString source = QString::fromUtf8(GAME_TEST_DATA_DIR) + QLatin1Char('/') + fileName; + const QString target = targetDir + QLatin1Char('/') + fileName; + if (QFile::copy(source, target)) { + return true; + } + + std::fprintf(stderr, "Failed to copy test fixture %s to %s\n", qPrintable(source), qPrintable(target)); + return false; +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + + // gtest_discover_tests runs the binary with --gtest_list_tests while the + // build is still in progress. No test runs there, and building the card + // database would leave process-lifetime allocations that the sanitizer + // leg's default leak detection reports at exit, failing discovery. + if (::testing::GTEST_FLAG(list_tests)) { + return RUN_ALL_TESTS(); + } + + QStandardPaths::setTestModeEnabled(true); + + QTemporaryDir home; + if (!home.isValid()) { + std::fprintf(stderr, "Failed to create a temporary home directory\n"); + return EXIT_FAILURE; + } + qputenv("HOME", home.path().toLocal8Bit()); + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules(QStringLiteral("card_database.loading=false\n" + "card_database.loading.success_or_failure=false\n" + "settings_cache.*=false")); + + // Copy the fixtures so the database loader never writes its .cache file into + // the source tree, then point the singleton at the copy. + QTemporaryDir dataDir; + if (!dataDir.isValid() || !QDir().mkpath(dataDir.filePath(QStringLiteral("customsets")))) { + std::fprintf(stderr, "Failed to create a temporary card database directory\n"); + return EXIT_FAILURE; + } + if (!copyFixture(QStringLiteral("cards.xml"), dataDir.path()) || + !copyFixture(QStringLiteral("tokens.xml"), dataDir.path()) || + !copyFixture(QStringLiteral("spoilers.xml"), dataDir.path())) { + return EXIT_FAILURE; + } + + // Function-local static: constructed before the CardDatabase singleton below, + // so it is destroyed after it and the raw pointer stored in the manager stays + // valid for the database's lifetime. + static GameTestPathProvider pathProvider(dataDir.path()); + CardDatabaseManager::setCardDatabasePathProvider(&pathProvider); + + CardDatabase *database = CardDatabaseManager::getInstance(); + database->loadCardDatabases(); + if (database->getLoadStatus() != Ok) { + std::fprintf(stderr, "Failed to load the game test card database (status %d)\n", + static_cast(database->getLoadStatus())); + return EXIT_FAILURE; + } + + return RUN_ALL_TESTS(); +} diff --git a/tests/game/player_actions_test.cpp b/tests/game/player_actions_test.cpp new file mode 100644 index 000000000..11ae7338d --- /dev/null +++ b/tests/game/player_actions_test.cpp @@ -0,0 +1,119 @@ +#include "game_test_fixture.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +TEST(PlayerActionsTest, PlayCreatureSendsMoveToTable) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + harness.player()->getPlayerActions()->playCard(cat, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.start_player_id(), 1); + EXPECT_EQ(command.start_zone(), std::string(ZoneNames::HAND)); + EXPECT_EQ(command.target_player_id(), 1); + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(0)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 7); + EXPECT_TRUE(card.has_face_down()); + EXPECT_FALSE(card.face_down()); + EXPECT_TRUE(card.has_pt()); + EXPECT_EQ(card.pt(), "3/3"); + EXPECT_TRUE(card.has_tapped()); + EXPECT_FALSE(card.tapped()); +} + +TEST(PlayerActionsTest, PlayInstantSendsMoveToStack) +{ + GameHarness harness; + CardState *bolt = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Lightning Bolt"), 8); + ASSERT_NE(bolt, nullptr); + + harness.player()->getPlayerActions()->playCard(bolt, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::STACK)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), 0); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 8); + // The stack branch deliberately leaves the other attributes unset. + EXPECT_FALSE(card.has_face_down()); + EXPECT_FALSE(card.has_pt()); + EXPECT_FALSE(card.has_tapped()); +} + +TEST(PlayerActionsTest, PlayFaceDownCreatureUsesFaceDownRow) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + harness.player()->getPlayerActions()->playCard(cat, true); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(2)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 7); + EXPECT_TRUE(card.has_face_down()); + EXPECT_TRUE(card.face_down()); + EXPECT_FALSE(card.has_pt()); + EXPECT_TRUE(card.has_tapped()); + EXPECT_FALSE(card.tapped()); +} + +TEST(PlayerActionsTest, PlayCiptCreatureSendsTapped) +{ + GameHarness harness; + CardState *bears = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(bears, nullptr); + + harness.player()->getPlayerActions()->playCard(bears, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 9); + EXPECT_TRUE(card.has_face_down()); + EXPECT_FALSE(card.face_down()); + EXPECT_TRUE(card.has_pt()); + EXPECT_EQ(card.pt(), "2/2"); + EXPECT_TRUE(card.has_tapped()); + EXPECT_TRUE(card.tapped()); +} diff --git a/tests/game/replay_test.cpp b/tests/game/replay_test.cpp new file mode 100644 index 000000000..218ffdf51 --- /dev/null +++ b/tests/game/replay_test.cpp @@ -0,0 +1,349 @@ +/** + * @file replay_test.cpp + * + * Replays a synthetic GameReplay through the real dispatcher. This is the only + * place that exercises PlayerLogic::processPlayerInfo / processCardAttachment + * end to end: the game state arrives as a GAME_STATE_CHANGED event and the + * graphics side is replaced by a small materializer that mirrors what + * CardZone::onCreateCardRequested does with the logic objects. + * + * The replay is built in code rather than from a .cor file: event application + * is deterministic (the client never shuffles on its own for a replay), and a + * checked-in binary fixture would be opaque about what it asserts. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +ServerInfo_Card makeCard(int id, const QString &name, int x = -1, int y = -1) +{ + ServerInfo_Card card; + card.set_id(id); + card.set_name(name.toStdString()); + card.set_x(x); + card.set_y(y); + return card; +} + +ServerInfo_Zone makeZone(const char *name, + ServerInfo_Zone::ZoneType type, + bool withCoords, + const QList &cards, + int cardCount = 0) +{ + ServerInfo_Zone zone; + zone.set_name(name); + zone.set_type(type); + zone.set_with_coords(withCoords); + for (const auto &card : cards) { + zone.add_card_list()->CopyFrom(card); + } + if (cardCount > 0) { + zone.set_card_count(cardCount); + } + return zone; +} + +ServerInfo_Player makePlayer(int playerId, const QString &name, const QList &zones) +{ + ServerInfo_Player player; + ServerInfo_PlayerProperties *properties = player.mutable_properties(); + properties->set_player_id(playerId); + properties->mutable_user_info()->set_name(name.toStdString()); + properties->mutable_user_info()->set_user_level(ServerInfo_User::IsUser); + for (const auto &zone : zones) { + player.add_zone_list()->CopyFrom(zone); + } + return player; +} + +template GameEventContainer makeContainer(int playerId, const EventType &message) +{ + GameEventContainer container; + container.set_game_id(42); + + GameEvent *event = container.add_event_list(); + event->set_player_id(playerId); + event->MutableExtension(EventType::ext)->CopyFrom(message); + + return container; +} + +/** + * Stands in for the graphics side of a replay: when the logic asks for a card + * item, create the CardState and add it to the zone, copying the state fields + * the real CardItem::processCardInfo applies. + * + * The production view owns the visual item; the state itself is all a headless + * test needs, and this is the smallest thing that lets processPlayerInfo build + * a real zone. + */ +class HeadlessZoneView : public QObject +{ +public: + using QObject::QObject; + + ~HeadlessZoneView() override + { + qDeleteAll(cards_); + cards_.clear(); + } + + void attach(PlayerLogic *player) + { + for (CardZoneLogic *zone : player->getZones()) { + connect(zone, &CardZoneLogic::createCardRequested, this, + [this, zone](const ServerInfo_Card &cardInfo, bool reorganize) { + materialize(zone, cardInfo, reorganize); + }); + } + } + +private: + void materialize(CardZoneLogic *zone, const ServerInfo_Card &info, bool reorganize) + { + const CardRef cardRef{QString::fromStdString(info.name()), QString::fromStdString(info.provider_id())}; + auto *card = new CardState(nullptr, zone, cardRef, info.id()); + // The real CardItem constructor refreshes the card info before + // processCardInfo overwrites the state fields. + card->refreshCardInfo(); + + card->clearCounters(); + for (int i = 0; i < info.counter_list_size(); ++i) { + card->insertCounter(info.counter_list(i).id(), info.counter_list(i).value()); + } + card->setAttacking(info.attacking()); + card->setFaceDown(info.face_down()); + card->setPT(QString::fromStdString(info.pt())); + card->setAnnotation(QString::fromStdString(info.annotation())); + card->setColor(QString::fromStdString(info.color())); + card->setTapped(info.tapped()); + card->setDestroyOnZoneChange(info.destroy_on_zone_change()); + card->setDoesntUntap(info.doesnt_untap()); + + zone->addCard(card, reorganize, info.x(), info.y()); + cards_.append(card); + } + + QList cards_; +}; + +/** Owns a Replay built from a GameReplay and feeds its containers through it. */ +class ReplayHarness +{ +public: + explicit ReplayHarness(GameReplay replay) + : replayProto_(std::move(replay)), view_(std::make_unique()), + replay_(std::make_unique(nullptr, &replayProto_, false)) + { + QObject::connect(replay_->getPlayerManager(), &PlayerManager::playerAdded, view_.get(), + &HeadlessZoneView::attach); + } + + ReplayHarness(const ReplayHarness &) = delete; + ReplayHarness &operator=(const ReplayHarness &) = delete; + + void processAll() + { + for (const GameEventContainer &container : replayProto_.event_list()) { + replay_->getGameEventHandler()->processGameEventContainer(container, nullptr, EventProcessingOptions()); + } + } + + [[nodiscard]] Replay *replay() const + { + return replay_.get(); + } + + [[nodiscard]] PlayerLogic *player(int playerId) const + { + return replay_->getPlayerManager()->getPlayer(playerId); + } + +private: + GameReplay replayProto_; + std::unique_ptr view_; + std::unique_ptr replay_; +}; + +GameReplay makeReplay(const QList &containers) +{ + GameReplay replay; + replay.mutable_game_info()->set_game_id(42); + replay.mutable_game_info()->set_started(true); + for (const auto &container : containers) { + replay.add_event_list()->CopyFrom(container); + } + return replay; +} + +Event_GameStateChanged initialGameState() +{ + Event_GameStateChanged state; + state.set_game_started(true); + state.set_active_player_id(1); + state.set_active_phase(0); + return state; +} + +} // namespace + +TEST(ReplayTest, MaterializesGameStateZonesAndAttachments) +{ + Event_GameStateChanged state = initialGameState(); + + ServerInfo_Card cat = makeCard(7, QStringLiteral("Cat")); + // Cross-player attachment: Alice's card is attached to Bob's creature. + cat.set_attach_player_id(2); + cat.set_attach_zone(ZoneNames::TABLE); + cat.set_attach_card_id(9); + + const ServerInfo_Player alice = makePlayer(1, QStringLiteral("Alice"), + {makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {cat}), + makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 2, 1)}), + makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)}); + const ServerInfo_Player bob = makePlayer(2, QStringLiteral("Bob"), + {makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 1, 0)})}); + + state.add_player_list()->CopyFrom(alice); + state.add_player_list()->CopyFrom(bob); + + ReplayHarness harness(makeReplay({makeContainer(-1, state)})); + harness.processAll(); + + PlayerLogic *aliceLogic = harness.player(1); + PlayerLogic *bobLogic = harness.player(2); + ASSERT_NE(aliceLogic, nullptr); + ASSERT_NE(bobLogic, nullptr); + + CardZoneLogic *hand = aliceLogic->getZones().value(ZoneNames::HAND); + CardZoneLogic *table = aliceLogic->getZones().value(ZoneNames::TABLE); + CardZoneLogic *deck = aliceLogic->getZones().value(ZoneNames::DECK); + ASSERT_NE(hand, nullptr); + ASSERT_NE(table, nullptr); + ASSERT_NE(deck, nullptr); + + ASSERT_EQ(hand->getCards().size(), 1); + CardState *catState = hand->getCards().first(); + EXPECT_EQ(catState->getId(), 7); + EXPECT_EQ(catState->getName(), QStringLiteral("Cat")); + + ASSERT_EQ(table->getCards().size(), 1); + CardState *host = table->getCards().first(); + EXPECT_EQ(host->getName(), QStringLiteral("Grizzly Bears")); + EXPECT_EQ(host->getGridPoint(), QPoint(2, 1)); + + // The hidden deck only carries a count, and the view materializes that many + // unknown cards. + EXPECT_EQ(deck->getCards().size(), 2); + for (const CardState *hidden : deck->getCards()) { + EXPECT_EQ(hidden->getId(), -1); + } + + // processCardAttachment links both sides after every player is materialized. + CardZoneLogic *bobTable = bobLogic->getZones().value(ZoneNames::TABLE); + ASSERT_NE(bobTable, nullptr); + ASSERT_EQ(bobTable->getCards().size(), 1); + CardState *bobHost = bobTable->getCards().first(); + EXPECT_EQ(catState->getAttachedTo(), bobHost); + ASSERT_EQ(bobHost->getAttachedCards().size(), 1); + EXPECT_EQ(bobHost->getAttachedCards().first(), catState); + // The identically numbered card on Alice's own table is a different card. + EXPECT_TRUE(host->getAttachedCards().isEmpty()); + + EXPECT_TRUE(harness.replay()->getGameState()->isGameStateKnown()); +} + +TEST(ReplayTest, AppliesMovesAttributesAndCountersInOrder) +{ + Event_GameStateChanged state = initialGameState(); + state.add_player_list()->CopyFrom(makePlayer( + 1, QStringLiteral("Alice"), + {makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {makeCard(7, QStringLiteral("Cat"))}), + makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 0, TableZoneLogic::tableRowToGridY(0))}), + makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)})); + + Event_MoveCard move; + move.set_card_id(7); + move.set_start_player_id(1); + move.set_start_zone(ZoneNames::HAND); + move.set_position(0); + move.set_target_player_id(1); + move.set_target_zone(ZoneNames::TABLE); + move.set_x(3); + move.set_y(TableZoneLogic::tableRowToGridY(1)); + move.set_new_card_id(7); + move.set_face_down(false); + + Event_SetCardAttr tap; + tap.set_zone_name(ZoneNames::TABLE); + tap.set_card_id(7); + tap.set_attribute(AttrTapped); + tap.set_attr_value("1"); + + Event_SetCardCounter counter; + counter.set_zone_name(ZoneNames::TABLE); + counter.set_card_id(7); + counter.set_counter_id(1); + counter.set_counter_value(2); + + ReplayHarness harness(makeReplay( + {makeContainer(-1, state), makeContainer(1, move), makeContainer(1, tap), makeContainer(1, counter)})); + harness.processAll(); + + PlayerLogic *alice = harness.player(1); + ASSERT_NE(alice, nullptr); + CardZoneLogic *hand = alice->getZones().value(ZoneNames::HAND); + CardZoneLogic *table = alice->getZones().value(ZoneNames::TABLE); + ASSERT_NE(hand, nullptr); + ASSERT_NE(table, nullptr); + + EXPECT_TRUE(hand->getCards().isEmpty()); + ASSERT_EQ(table->getCards().size(), 2); + + CardState *moved = table->getCard(7); + ASSERT_NE(moved, nullptr); + EXPECT_EQ(moved->getGridPoint(), QPoint(3, TableZoneLogic::tableRowToGridY(1))); + EXPECT_EQ(moved->getPT(), QStringLiteral("3/3")); + EXPECT_TRUE(moved->getTapped()); + EXPECT_EQ(moved->getCounters().value(1), 2); + + // The untouched creature keeps its own position and state. + CardState *bears = table->getCard(9); + ASSERT_NE(bears, nullptr); + EXPECT_EQ(bears->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0))); + EXPECT_FALSE(bears->getTapped()); +} From 94e6281d87a8e55e1e150463ae0df78eece1676f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:36:49 +0100 Subject: [PATCH 184/247] [Logs] Browse room history without filters with paginated infinite scroll (#7531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Logs] Browse room history without filters with paginated infinite scroll * [Logs] Continue pagination when switching log tabs --------- Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_logs.cpp | 283 +++++++++++++----- .../src/interface/widgets/tabs/tab_logs.h | 31 ++ .../protocol/pb/moderator_commands.proto | 18 +- .../pb/response_viewlog_history.proto | 1 + .../src/servatrice_database_interface.cpp | 64 +++- .../src/servatrice_database_interface.h | 5 +- servatrice/src/serversocketinterface.cpp | 10 +- 7 files changed, 321 insertions(+), 91 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index 67e076983..9509f48ec 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +33,17 @@ class TabSupervisor; +namespace +{ +/** @brief Identifies the newest row of a page well enough to recognise a repeated page. */ +QString pageStartKey(const Response_ViewLogHistory &response) +{ + const ServerInfo_ChatMessage &first = response.log_message(0); + return QString::fromStdString(first.time()) + QChar(0x1f) + QString::fromStdString(first.sender_name()) + + QChar(0x1f) + QString::fromStdString(first.message()); +} +} // namespace + TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { @@ -52,12 +65,20 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can chatTable->setHorizontalHeaderLabels( QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";")); - QTabWidget *tabManager = new QTabWidget(); + tabManager = new QTabWidget(); tabManager->addTab(roomTable, tr("Room Logs")); tabManager->addTab(gameTable, tr("Game Logs")); tabManager->addTab(chatTable, tr("Chat Logs")); setCentralWidget(tabManager); + connect(roomTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + connect(gameTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + connect(chatTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + // A tab that gains only a few rows has nothing to scroll, so its scrollbar never fires: + // switching to it would leave its next page unfetched until the moderator scrolled + // another tab. Re-check on every tab switch so the visible table can continue the browse. + connect(tabManager, &QTabWidget::currentChanged, this, &TabLog::checkFetchMore); + createDock(); restartLayout(); clearClicked(); @@ -74,12 +95,9 @@ void TabLog::retranslateUi() void TabLog::getClicked() { - if (findUsername->text().isEmpty() && findIPAddress->text().isEmpty() && findGameName->text().isEmpty() && - findGameID->text().isEmpty() && findMessage->text().isEmpty()) { - QMessageBox::critical(this, tr("Error"), tr("You must select at least one filter.")); - return; - } - + // Empty filters are a valid browse: room/game history going back from + // today, paginated. Only require a date range + location, both of which + // get sensible defaults below so a bare "Get" just works. if (!lastHour->isChecked() && !today->isChecked() && !pastDays->isChecked()) { pastDays->setChecked(true); pastXDays->setValue(20); @@ -99,39 +117,71 @@ void TabLog::getClicked() } if (maximumResults->value() == 0) { - maximumResults->setValue(1000); + maximumResults->setValue(200); } + pageSize = qBound(1, maximumResults->value(), 1000); - int dateRange = 0; - if (lastHour->isChecked()) { - dateRange = 1; - } - - if (today->isChecked()) { - dateRange = 24; - } - - if (pastDays->isChecked()) { - dateRange = pastXDays->value() * 24; - } - - Command_ViewLogHistory cmd; - cmd.set_user_name(findUsername->text().toStdString()); - cmd.set_ip_address(findIPAddress->text().toStdString()); - cmd.set_game_name(findGameName->text().toStdString()); - cmd.set_game_id(findGameID->text().toStdString()); - cmd.set_message(findMessage->text().toStdString()); + // Build the command once, here, and let every page copy it: rebuilding it from the + // widgets per page would run page 2+ against filters edited after Get was clicked, + // appending rows selected by a different query under the first page's results. + currentQuery.Clear(); + currentQuery.set_user_name(findUsername->text().toStdString()); + currentQuery.set_ip_address(findIPAddress->text().toStdString()); + currentQuery.set_game_name(findGameName->text().toStdString()); + currentQuery.set_game_id(findGameID->text().toStdString()); + currentQuery.set_message(findMessage->text().toStdString()); if (mainRoom->isChecked()) { - cmd.add_log_location("room"); - }; + currentQuery.add_log_location("room"); + } if (gameRoom->isChecked()) { - cmd.add_log_location("game"); - }; + currentQuery.add_log_location("game"); + } if (privateChat->isChecked()) { - cmd.add_log_location("chat"); - }; - cmd.set_date_range(dateRange); - cmd.set_maximum_results(maximumResults->value()); + currentQuery.add_log_location("chat"); + } + currentQuery.set_date_range(dateRangeForSelection()); + currentQuery.set_maximum_results(pageSize); + hasCurrentQuery = true; + snapshotBefore.clear(); + lastPageStartKey.clear(); + + // Fresh search: reset pagination state and clear previous pages. + currentOffset = 0; + roomCounter = 0; + gameCounter = 0; + chatCounter = 0; + roomTable->setRowCount(0); + gameTable->setRowCount(0); + chatTable->setRowCount(0); + + fetchPage(0); +} + +int TabLog::dateRangeForSelection() const +{ + if (lastHour->isChecked()) { + return 1; + } + if (today->isChecked()) { + return 24; + } + if (pastDays->isChecked()) { + return pastXDays->value() * 24; + } + return 0; +} + +void TabLog::fetchPage(int offset) +{ + if (isFetching || !hasCurrentQuery) { + return; + } + + Command_ViewLogHistory cmd = currentQuery; + cmd.set_offset(offset); + if (!snapshotBefore.isEmpty()) { + cmd.set_before_time(snapshotBefore.toStdString()); + } PendingCommand *pend; if (canUseDeveloperCommands) { @@ -141,10 +191,42 @@ void TabLog::getClicked() pend = client->prepareModeratorCommand(cmd); } + isFetching = true; + getButton->setEnabled(false); + connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse); client->sendCommand(pend); } +void TabLog::checkFetchMore() +{ + if (isFetching || !hasMoreResults) { + return; + } + + // Only the table the moderator is looking at may pull the next page. Checking all + // three would make an empty or hidden table nearBottom by definition (maximum() and + // value() are both zero), so a room-only browse chained page after page without any + // scrolling - pulling the whole range with a growing OFFSET scan per page, and + // looping forever against a server that ignores `offset`. + auto *table = qobject_cast(tabManager->currentWidget()); + if (table == nullptr || table->rowCount() == 0) { + return; + } + + // ScrollPerItem (the default) counts rows, not pixels: `value` is the top visible row + // and `maximum` the last one that can be scrolled to. Prefetch half a page ahead so + // scrolling never visibly stalls, without the fixed 300-row threshold firing the next + // page the moment a page loads. + const QScrollBar *bar = table->verticalScrollBar(); + const int prefetchRows = qMax(1, pageSize / 2); + if (bar->maximum() - bar->value() > prefetchRows) { + return; + } + + fetchPage(currentOffset); +} + void TabLog::clearClicked() { findUsername->clear(); @@ -166,6 +248,10 @@ void TabLog::clearClicked() pastDays->setAutoExclusive(true); today->setAutoExclusive(true); lastHour->setAutoExclusive(true); + currentOffset = 0; + hasMoreResults = false; + hasCurrentQuery = false; + snapshotBefore.clear(); } void TabLog::createDock() @@ -209,14 +295,15 @@ void TabLog::createDock() pastXDays = new QSpinBox; pastXDays->setMaximum(20); - labelMaximum = new QLabel(tr("Maximum Results: ")); + labelMaximum = new QLabel(tr("Results per page: ")); maximumResults = new QSpinBox; maximumResults->setMaximum(1000); + maximumResults->setToolTip(tr("Page size for smooth scrolling: more rows load automatically as you scroll.")); - labelDescription = new QLabel(tr( - "At least one filter is required.\nThe more information you put in, the more specific your results will be.")); + labelDescription = new QLabel(tr("Leave filters empty to browse recent room and game messages.\n" + "More rows load automatically as you scroll.")); - getButton = new QPushButton(tr("Get User Logs")); + getButton = new QPushButton(tr("Get Logs")); getButton->setAutoDefault(true); connect(getButton, &QPushButton::clicked, this, &TabLog::getClicked); @@ -262,7 +349,7 @@ void TabLog::createDock() maxResultsGrid->addWidget(labelMaximum, 0, 0); maxResultsGrid->addWidget(maximumResults, 0, 1); - maxResultsGroupBox = new QGroupBox(tr("Maximum Results")); + maxResultsGroupBox = new QGroupBox(tr("Results per page")); maxResultsGroupBox->setLayout(maxResultsGrid); descriptionGrid = new QGridLayout; @@ -295,84 +382,134 @@ void TabLog::createDock() searchDock->setWidget(searchDockContents); } +void TabLog::appendChatMessage(QTableWidget *table, + int &counter, + const QString &time, + const QString &senderName, + const QString &senderIp, + const QString &messageText, + const QString &targetId, + const QString &targetName, + const QString &channel) +{ + table->insertRow(counter); + table->setItem(counter, 0, new QTableWidgetItem(time)); + table->setItem(counter, 1, new QTableWidgetItem(senderName)); + table->setItem(counter, 2, new QTableWidgetItem(senderIp)); + table->setItem(counter, 3, new QTableWidgetItem(messageText)); + table->setItem(counter, 4, new QTableWidgetItem(targetId)); + table->setItem(counter, 5, new QTableWidgetItem(targetName)); + if (table->columnCount() > 6) { + table->setItem(counter, 6, new QTableWidgetItem(channel)); + } + ++counter; +} + void TabLog::viewLogHistory_processResponse(const Response &resp) { const Response_ViewLogHistory &response = resp.GetExtension(Response_ViewLogHistory::ext); + const bool isFirstPage = (currentOffset == 0); + isFetching = false; + getButton->setEnabled(true); + if (resp.response_code() != Response::RespOk) { QMessageBox::critical(static_cast(parent()), tr("Message History"), tr("Failed to collect message history information.")); return; } - if (response.log_message_size() == 0) { + if (isFirstPage && response.log_message_size() == 0) { QMessageBox::information(static_cast(parent()), tr("Message History"), tr("There are no messages for the selected filters.")); + hasMoreResults = false; return; } - int roomCounter = 0, gameCounter = 0, chatCounter = 0; - roomTable->setRowCount(roomCounter); - gameTable->setRowCount(gameCounter); - chatTable->setRowCount(chatCounter); + const QString thisPageStart = response.log_message_size() > 0 ? pageStartKey(response) : QString(); + + if (isFirstPage) { + // Rows come newest first, so the first one fixes the snapshot every later page is + // bounded by. Without it, each message logged between two page fetches pushes the + // window down by one and repeats a row the moderator already has. + snapshotBefore = QString::fromStdString(response.log_message(0).time()); + } else if (!response.has_has_more() && !thisPageStart.isEmpty() && thisPageStart == lastPageStartKey) { + // A server that predates `offset` ignores it and returns the same page again. It + // also predates `has_more`, so the full-page heuristic below can never prove + // exhaustion and every scroll would append the same rows forever. The identical + // first row is the proof; stop instead of looping. + hasMoreResults = false; + return; + } + lastPageStartKey = thisPageStart; for (int i = 0; i < response.log_message_size(); ++i) { ServerInfo_ChatMessage message = response.log_message(i); const QString targetType = QString::fromStdString(message.target_type()); if (targetType == "room" || targetType == "room_channel") { - roomTable->insertRow(roomCounter); - roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - roomTable->setItem(roomCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel()))); - ++roomCounter; + appendChatMessage(roomTable, roomCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name()), QString::fromStdString(message.channel())); } if (targetType == "game") { - gameTable->insertRow(gameCounter); - gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - gameTable->setItem(gameCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - gameTable->setItem(gameCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - gameTable->setItem(gameCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - gameTable->setItem(gameCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - ++gameCounter; + appendChatMessage(gameTable, gameCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name())); } if (targetType == "chat") { - chatTable->insertRow(chatCounter); - chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - chatTable->setItem(chatCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - chatTable->setItem(chatCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - chatTable->setItem(chatCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - chatTable->setItem(chatCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - ++chatCounter; + appendChatMessage(chatTable, chatCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name())); } } + currentOffset += response.log_message_size(); + if (response.has_has_more()) { + hasMoreResults = response.has_more(); + } else { + // Legacy server without has_more: a full page implies more may exist. + hasMoreResults = (response.log_message_size() >= pageSize); + } + if (roomCounter) { roomTable->show(); - roomTable->resizeColumnsToContents(); + if (isFirstPage) { + roomTable->resizeColumnsToContents(); + } } else { roomTable->hide(); } if (gameCounter) { - gameTable->resizeColumnsToContents(); + if (isFirstPage) { + gameTable->resizeColumnsToContents(); + } gameTable->show(); } else { gameTable->hide(); } if (chatCounter) { - chatTable->resizeColumnsToContents(); + if (isFirstPage) { + chatTable->resizeColumnsToContents(); + } chatTable->show(); } else { chatTable->hide(); } + + // The new rows may already fill less than a viewport (or the user is + // still parked at the bottom), so chain the next page without waiting + // for another scroll event. The prefetch stays invisible: rows simply + // keep appearing as you scroll. + checkFetchMore(); } void TabLog::restartLayout() diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h index fc8ef1ea0..f2f5842c5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -10,6 +10,7 @@ #include "tab.h" #include +#include #include class AbstractClient; @@ -25,6 +26,7 @@ class QWidget; class QGridLayout; class QVBoxLayout; class QTableWidget; +class QTabWidget; class Response; class TabSupervisor; @@ -48,8 +50,37 @@ private: *buttonGroupBox; QVBoxLayout *mainLayout; QTableWidget *roomTable, *gameTable, *chatTable; + QTabWidget *tabManager = nullptr; + + //! The filters of the search in flight, captured when Get was clicked so that + //! later pages cannot pick up edits the moderator made after starting it. + Command_ViewLogHistory currentQuery; + bool hasCurrentQuery = false; + //! Newest `log_time` of the first page, sent back as the upper bound of every + //! later page so messages logged in the meantime do not shift the window. + QString snapshotBefore; + //! Identity of the previous page's first row, used to spot a server that ignores + //! `offset` and hands back the same page forever. + QString lastPageStartKey; + int currentOffset = 0; + int pageSize = 200; + int roomCounter = 0, gameCounter = 0, chatCounter = 0; + bool isFetching = false; + bool hasMoreResults = false; void createDock(); + [[nodiscard]] int dateRangeForSelection() const; + void fetchPage(int offset); + void appendChatMessage(QTableWidget *table, + int &counter, + const QString &time, + const QString &senderName, + const QString &senderIp, + const QString &messageText, + const QString &targetId, + const QString &targetName, + const QString &channel = QString()); + void checkFetchMore(); signals: private slots: diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 292917fa9..04cb31d98 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -87,14 +87,16 @@ message Command_ViewLogHistory { extend DeveloperCommand { optional Command_ViewLogHistory dev_ext = 1001; } - optional string user_name = 1; // user that created message - optional string ip_address = 2; // ip address of user that created message - optional string game_name = 3; // client id of user that created the message - optional string game_id = 4; // game number the message was sent to - optional string message = 5; // raw message that was sent - repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) - required uint32 date_range = 7; // the length of time (in minutes) to look back for - optional uint32 maximum_results = 8; // the maximum number of query results + optional string user_name = 1; // user that created message + optional string ip_address = 2; // ip address of user that created message + optional string game_name = 3; // client id of user that created the message + optional string game_id = 4; // game number the message was sent to + optional string message = 5; // raw message that was sent + repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) + required uint32 date_range = 7; // the length of time (in minutes) to look back for + optional uint32 maximum_results = 8; // the maximum number of query results (page size when paginating) + optional uint32 offset = 9 [default = 0]; // rows to skip for pagination, ORDER BY log_time DESC + optional string before_time = 10; // page those rows against this `log_time` snapshot (newest first page row) } message Command_GrantReplayAccess { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto index 2ec0aff28..1041bca74 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto @@ -7,4 +7,5 @@ message Response_ViewLogHistory { optional Response_ViewLogHistory ext = 1015; } repeated ServerInfo_ChatMessage log_message = 1; + optional bool has_more = 2 [default = false]; // true when more rows exist past this page } diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 45ae9bde8..a4f184e38 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -2249,8 +2250,14 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory bool &game, bool &room, int &range, - int &maxresults) + int &maxresults, + int offset, + const QString &beforeTime, + bool *hasMore) { + if (hasMore != nullptr) { + *hasMore = false; + } QList results; ServerInfo_ChatMessage chatMessage; @@ -2259,8 +2266,31 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory return results; } - if (user.isEmpty() && ipaddress.isEmpty() && gameid.isEmpty() && gamename.isEmpty()) { - // To ensure quick results and minimal lag, require an indexed field + // Page size guard: always bound the scan so a filter-less browse of room + // history cannot become a 50M-row fetch. Zero means "client did not set a + // size", so fall back to one smooth-scroll page. + const int pageSize = maxresults <= 0 ? 200 : qBound(1, maxresults, 1000); + maxresults = pageSize; + const int safeOffset = qMax(0, offset); + + const bool hasIndexedFilter = !user.isEmpty() || !ipaddress.isEmpty() || !gameid.isEmpty() || !gamename.isEmpty(); + const bool hasLocationFilter = chat || game || room; + if (!hasLocationFilter) { + // No target_type restriction would mix private chat into a room/game + // browse, so refuse rather than leak. + return results; + } + // A browse narrowed only by a message substring has no index to serve it, and `range` + // has no upper bound on the wire: the client offers 20 days, but a hand-built command + // can ask for any value and every LIKE candidate in it is a row the server examines. + // Clamping here keeps the guard independent of what the client happens to send. + constexpr int maxFilterlessRangeHours = 20 * 24; + if (!hasIndexedFilter && range > maxFilterlessRangeHours) { + range = maxFilterlessRangeHours; + } + if (!hasIndexedFilter && range <= 0) { + // Filter-less browse must stay time-bounded (idx_log_time) so the + // query stays an index range scan instead of a full table scan. return results; } @@ -2315,10 +2345,23 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory queryString.append(" AND log_time >= DATE_SUB(now(), INTERVAL :range_time HOUR)"); } - if (maxresults) { - queryString.append(" LIMIT :limit_size"); + if (!beforeTime.isEmpty()) { + // Pages after the first run against the snapshot fixed by the first one, so rows + // logged while the moderator scrolls can no longer push the OFFSET window down and + // repeat a row already on screen. + queryString.append(" AND log_time <= :before_time"); } + // Newest-first keeps "going back from today" stable across pages and lets + // the client append older rows at the bottom for smooth infinite scroll. + queryString.append(" ORDER BY log_time DESC"); + + // Fetch one extra row when the caller wants a has_more cursor so a full + // page does not need a second empty round-trip to prove exhaustion. + const bool detectMore = (hasMore != nullptr); + const int fetchSize = detectMore ? pageSize + 1 : pageSize; + queryString.append(" LIMIT :limit_size OFFSET :offset_size"); + QSqlQuery *query = prepareQuery(queryString); if (!user.isEmpty()) { query->bindValue(":user_name", user); @@ -2338,9 +2381,11 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory if (range) { query->bindValue(":range_time", range); } - if (maxresults) { - query->bindValue(":limit_size", maxresults); + if (!beforeTime.isEmpty()) { + query->bindValue(":before_time", beforeTime); } + query->bindValue(":limit_size", fetchSize); + query->bindValue(":offset_size", safeOffset); if (!execSqlQuery(query)) { qCWarning(DatabaseInterfaceLog) << "Failed to collect log history information: SQL Error"; @@ -2360,6 +2405,11 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory results << chatMessage; } + if (detectMore && results.size() > pageSize) { + results.removeLast(); + *hasMore = true; + } + return results; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index a27bd55b8..eb74063ac 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -219,7 +219,10 @@ public: bool &game, bool &room, int &range, - int &maxresults); + int &maxresults, + int offset = 0, + const QString &beforeTime = QString(), + bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); QList getUserAlts(const QString &userName); QList getModeratorLastLogins(); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 67e807380..c38d790b1 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -1743,12 +1743,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com int dateRange = cmd.date_range(); int maximumResults = cmd.maximum_results(); + const int offset = cmd.has_offset() ? static_cast(cmd.offset()) : 0; + const QString beforeTime = cmd.has_before_time() ? QString::fromStdString(cmd.before_time()) : QString(); Response_ViewLogHistory *re = new Response_ViewLogHistory; if (servatrice->getEnableLogQuery()) { - QListIterator messageIterator(sqlInterface->getMessageLogHistory( - userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults)); + bool hasMore = false; + QList history = + sqlInterface->getMessageLogHistory(userName, ipAddress, gameName, gameID, message, chatType, gameType, + roomType, dateRange, maximumResults, offset, beforeTime, &hasMore); + QListIterator messageIterator(history); while (messageIterator.hasNext()) { ServerInfo_ChatMessage chatMessage = messageIterator.next(); if (!allowPrivateChat) { @@ -1756,6 +1761,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com } re->add_log_message()->CopyFrom(chatMessage); } + re->set_has_more(hasMore); } else { ServerInfo_ChatMessage chatMessage; From a1a8a59f4a7a5d27cf3f7252beadbbac334d3378 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:35:58 +0100 Subject: [PATCH 185/247] [VisualDatabase] Draw the card grid with a model/view (#7701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VisualDatabase] Draw the card grid with a model/view instead of a widget per card The visual card database built one CardInfoPictureWithTextOverlayWidget per card, 100 per page, and tore them down on every search, resize and scroll page. It also looked each card up again by name from the model row, kept a shadow QList, and carried paging state plus a 5 s timer that only existed to defer the first page. Replace that with a real view on a flattened model: - CardDatabaseDisplayModel gains CardInfoRole and cardInfoAt(), so a view can get the CardInfoPtr behind a proxy row without a name round trip. - New CardDatabaseVisualDisplayModel (libcockatrice_models) flattens the filtered and sorted proxy into one entry per displayed card picture: the preferred printing normally, every printing of the selected sets while set filters are active. Proxy changes rebuild it on a 30 ms coalescing timer. - New CardDatabaseVisualView (QListView IconMode) paints only visible rows through the new CardPictureDelegate, keeps the old interaction contract (left/right/middle press -> cardClicked, hover -> cardHovered, Ctrl+wheel resize, no selection, no focus stealing, scroll to top on a new result) and repaints when CardPictureLoader reports an async image. - CardSizeWidget::enableCtrlScrollResize() also watches scroll-area viewports, which is what makes Ctrl+wheel work over a QListView. - VisualDatabaseDisplayWidget drops the paging, the shadow list, the dead members and the 5 s timer. Behaviour-preserving: no gameplay change, no new user-facing strings. * [VisualDatabase] Fix the card grid wheel step QListView hands the delegate's item height to the scroll bar as its single step under ScrollPerPixel, so QAbstractSlider moved wheelScrollLines() whole card rows per notch. Handle the wheel in the view instead: one line is an eighth of the grid cell, so a notch moves about a third of a card row at any card size and still follows the OS line count. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 + cockatrice/src/filters/filter_tree_model.cpp | 72 +++--- cockatrice/src/filters/filter_tree_model.h | 14 ++ .../card_picture_loader.cpp | 1 + .../card_picture_loader/card_picture_loader.h | 8 + .../cards/card_info_picture_widget.cpp | 19 +- .../widgets/cards/card_info_picture_widget.h | 11 + .../widgets/cards/card_picture_delegate.cpp | 83 +++++++ .../widgets/cards/card_picture_delegate.h | 45 ++++ .../widgets/cards/card_size_widget.cpp | 30 ++- .../widgets/cards/card_size_widget.h | 9 +- .../card_database_visual_view.cpp | 203 +++++++++++++++++ .../card_database_visual_view.h | 67 ++++++ .../visual_database_display_widget.cpp | 215 +++--------------- .../visual_database_display_widget.h | 49 ++-- .../libcockatrice/filters/filter_card.h | 2 +- .../models/database/CMakeLists.txt | 2 + .../database/card_database_display_model.cpp | 22 +- .../database/card_database_display_model.h | 24 ++ .../card_database_visual_display_model.cpp | 123 ++++++++++ .../card_database_visual_display_model.h | 90 ++++++++ tests/carddatabase/CMakeLists.txt | 15 ++ .../visual_display_model_test.cpp | 188 +++++++++++++++ vcpkg | 2 +- 24 files changed, 1019 insertions(+), 277 deletions(-) create mode 100644 cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/cards/card_picture_delegate.h create mode 100644 cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp create mode 100644 cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h create mode 100644 libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp create mode 100644 libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h create mode 100644 tests/carddatabase/visual_display_model_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ce7ad5712..642d998a0 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -171,6 +171,7 @@ set(cockatrice_SOURCES src/interface/widgets/cards/card_info_picture_widget.cpp src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp src/interface/widgets/cards/card_info_text_widget.cpp + src/interface/widgets/cards/card_picture_delegate.cpp src/interface/widgets/cards/card_size_widget.cpp src/interface/widgets/cards/deck_card_zone_display_widget.cpp src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -290,6 +291,7 @@ set(cockatrice_SOURCES src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h + src/interface/widgets/visual_database_display/card_database_visual_view.cpp src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index b3bf307f1..9b55ce2fd 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -11,6 +11,27 @@ class QObject; +namespace +{ +/** + * @brief Visits every filter item below @p node, depth first. + */ +template void forEachFilter(const FilterTreeNode *node, const Callback &callback) +{ + if (node == nullptr) { + return; + } + + if (const auto *filterItem = dynamic_cast(node)) { + callback(filterItem); + } + + for (int i = 0; i < node->childCount(); ++i) { + forEachFilter(node->nodeAt(i), callback); + } +} +} // namespace + FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) { fTree = new FilterTree; @@ -110,46 +131,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType) QList FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const { QList filters; - if (!fTree) { - return filters; - } - - std::function traverse; - traverse = [&](const FilterTreeNode *node) { - if (const auto *filterItem = dynamic_cast(node)) { - if (filterItem->attr() == filterType) { - QString text = filterItem->text(); - filters.append(new CardFilter(text, filterItem->type(), filterItem->attr())); - } + forEachFilter(fTree, [&](const FilterItem *filterItem) { + if (filterItem->attr() == filterType) { + filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr())); } - for (int i = 0; i < node->childCount(); ++i) { - traverse(node->nodeAt(i)); - } - }; - - traverse(fTree); + }); return filters; } +QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const +{ + QStringList terms; + forEachFilter(fTree, [&](const FilterItem *filterItem) { + if (filterItem->attr() == filterType) { + terms.append(filterItem->text()); + } + }); + return terms; +} + QList FilterTreeModel::allFilters() const { QList filters; - if (!fTree) { - return filters; - } - - std::function traverse; - traverse = [&](const FilterTreeNode *node) { - if (const auto *filterItem = dynamic_cast(node)) { - QString text = filterItem->text(); - filters.append(new CardFilter(text, filterItem->type(), filterItem->attr())); - } - for (int i = 0; i < node->childCount(); ++i) { - traverse(node->nodeAt(i)); - } - }; - - traverse(fTree); + forEachFilter(fTree, [&](const FilterItem *filterItem) { + filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr())); + }); return filters; } diff --git a/cockatrice/src/filters/filter_tree_model.h b/cockatrice/src/filters/filter_tree_model.h index 826fcc941..05f34f508 100644 --- a/cockatrice/src/filters/filter_tree_model.h +++ b/cockatrice/src/filters/filter_tree_model.h @@ -28,7 +28,14 @@ public slots: void addFilter(const CardFilter *f); void removeFilter(const CardFilter *f); void clearFiltersOfType(CardFilter::Attr filterType); + /** + * @brief Copies every active filter of one attribute into new CardFilter objects. + * + * The caller owns the returned filters. Prefer getFilterTermsOfType() when only + * the terms are needed. + */ [[nodiscard]] QList getFiltersOfType(CardFilter::Attr filterType) const; + /// @brief Copies every active filter into new CardFilter objects. The caller owns them. [[nodiscard]] QList allFilters() const; private slots: @@ -48,6 +55,13 @@ public: { return fTree; } + + /** + * @brief The texts of every active filter of one attribute, without allocating filters. + * + * Prefer this over getFiltersOfType() when only the filter terms are needed. + */ + [[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const; [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; [[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override; [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 4f77c7096..2d35538d2 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -238,6 +238,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) }); card.emitPixmapUpdated(); + emit getInstance().pixmapLoaded(card); } void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index c6d91570b..0c73e5d59 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -146,6 +146,14 @@ signals: /** @brief Emitted after the worker has finished clearing the network and redirect caches. */ void networkCacheCleared(); + /** + * @brief Emitted once an image has been loaded and put into the pixmap cache. + * + * Views that paint cards from the loader without owning a widget per card can + * listen to this to repaint the rows whose picture just became available. + */ + void pixmapLoaded(const ExactCard &card); + private slots: /** * @brief Triggered when the user changes the picture download settings. diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 0d94e7209..8f02ebcde 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -43,11 +43,6 @@ #include #include -static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396; -// static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457; -static constexpr qreal ASPECT_RATIO = MTG_CARD_ASPECT_RATIO; - -static constexpr int BASE_WIDTH = 200; static constexpr int BASE_HEIGHT = 200; static constexpr int ENLARGED_PIXMAP_OFFSET = 10; @@ -162,12 +157,11 @@ void CardInfoPictureWidget::resizeEvent(QResizeEvent *event) */ void CardInfoPictureWidget::setScaleFactor(const int scale) { - const int newWidth = BASE_WIDTH * scale / 100; - const int newHeight = static_cast(newWidth * ASPECT_RATIO); + const QSize newSize = sizeForScaleFactor(scale); scaleFactor = scale; - setFixedSize(newWidth, newHeight); + setFixedSize(newSize); updatePixmap(); emit cardScaleFactorChanged(scale); @@ -266,8 +260,13 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) */ QSize CardInfoPictureWidget::sizeHint() const { - return {static_cast(BASE_WIDTH * scaleFactor / 100.0), - static_cast(BASE_WIDTH * scaleFactor / 100.0 * ASPECT_RATIO)}; + return sizeForScaleFactor(scaleFactor); +} + +QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor) +{ + const int width = BASE_WIDTH * scaleFactor / 100; + return {width, static_cast(width * ASPECT_RATIO)}; } /** diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 8d2d63736..0eb731d92 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -28,6 +28,11 @@ class CardInfoPictureWidget : public QWidget Q_OBJECT public: + /// Width of a card picture at 100 % scale, in pixels. + static constexpr int BASE_WIDTH = 200; + /// Height / width of a card picture. + static constexpr qreal ASPECT_RATIO = 1.396; + explicit CardInfoPictureWidget(QWidget *parent = nullptr, bool hoverToZoomEnabled = false, bool raiseOnEnter = false); @@ -37,6 +42,12 @@ public: } [[nodiscard]] QSize sizeHint() const override; + /** + * @brief The size a card picture has at @p scaleFactor percent. + * @param scaleFactor Percentage of BASE_WIDTH, as set by the card size slider. + */ + [[nodiscard]] static QSize sizeForScaleFactor(int scaleFactor); + public slots: void setCard(const ExactCard &card); void setScaleFactor(int scale); // New slot for scaling diff --git a/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp b/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp new file mode 100644 index 000000000..781c767ef --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp @@ -0,0 +1,83 @@ +#include "card_picture_delegate.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader.h" +#include "card_art_utils.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/settings/cards_display_settings.h" + +#include +#include +#include +#include +#include +#include + +CardPictureDelegate::CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent) + : QStyledItemDelegate(parent), model(_model) +{ +} + +void CardPictureDelegate::setCardSize(const QSize &size) +{ + if (cardSize == size) { + return; + } + + cardSize = size; +} + +void CardPictureDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (model == nullptr) { + return; + } + + const ExactCard card = model->cardAt(index.row()); + if (!card) { + return; + } + + // The grid cell is the card plus SPACING; give the picture the rest. + const int inset = SPACING / 2; + const QRect cardArea = option.rect.adjusted(inset, inset, -inset, -inset); + + QPixmap pixmap; + CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, cardArea.size()); + CardPictureLoader::getPixmap(pixmap, card, cardArea.size()); + + if (pixmap.isNull()) { + return; + } + + if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) { + pixmap = CardArtUtils::rotateSidewaysLayoutArt(pixmap, card); + } + + // Fit the picture into the cell and center it, like CardInfoPictureWidget does. + const QSize pictureSize = pixmap.deviceIndependentSize().toSize().scaled(cardArea.size(), Qt::KeepAspectRatio); + const QRect pictureRect(cardArea.center().x() - pictureSize.width() / 2, + cardArea.center().y() - pictureSize.height() / 2, pictureSize.width(), + pictureSize.height()); + + const qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners() + ? 0.05 * static_cast(pictureRect.width()) + : 0.0; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + painter->setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath shape; + shape.addRoundedRect(pictureRect, radius, radius); + painter->setClipPath(shape); + // Source rect is in device pixels, target in logical ones: drawPixmap scales + // between them without the device pixel ratio being applied twice. + painter->drawPixmap(pictureRect, pixmap, QRectF(pixmap.rect())); + painter->restore(); +} + +QSize CardPictureDelegate::sizeHint(const QStyleOptionViewItem & /*option*/, const QModelIndex & /*index*/) const +{ + return cardSize + QSize(SPACING, SPACING); +} diff --git a/cockatrice/src/interface/widgets/cards/card_picture_delegate.h b/cockatrice/src/interface/widgets/cards/card_picture_delegate.h new file mode 100644 index 000000000..31e673624 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_picture_delegate.h @@ -0,0 +1,45 @@ +/** + * @file card_picture_delegate.h + * @ingroup CardWidgets + * @brief Paints a card picture for a visual card grid item. + */ + +#ifndef CARD_PICTURE_DELEGATE_H +#define CARD_PICTURE_DELEGATE_H + +#include +#include +#include + +class CardDatabaseVisualDisplayModel; + +/** + * @brief Draws the card of a CardDatabaseVisualDisplayModel row as a picture. + * + * The delegate only paints rows the view asks for, so a grid of the whole card + * database stays cheap: no widget per card and no pixmap loaded for items that + * are off screen. Card images come from CardPictureLoader, which caches them and + * loads missing ones asynchronously. + */ +class CardPictureDelegate : public QStyledItemDelegate +{ + Q_OBJECT + +public: + /// Gap left around a card picture inside its grid cell, in pixels. + static constexpr int SPACING = 4; + + explicit CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent = nullptr); + + /// The picture size inside a grid cell. The cell is this size plus SPACING. + void setCardSize(const QSize &size); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + CardDatabaseVisualDisplayModel *model; + QSize cardSize; +}; + +#endif // CARD_PICTURE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp index ab1840591..ac1ab9cb1 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp @@ -2,6 +2,7 @@ #include "../general/layout_containers/flow_widget.h" +#include #include #include #include @@ -33,8 +34,8 @@ CardSizeWidget::CardSizeWidget(QWidget *parent, FlowWidget *_flowWidget, int def cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); cardSizeSlider = new QSlider(Qt::Horizontal, this); - cardSizeSlider->setRange(50, 250); ///< Slider range for card size adjustment. - cardSizeSlider->setValue(defaultValue); ///< Initial slider value. + cardSizeSlider->setRange(MIN_CARD_SIZE, MAX_CARD_SIZE); ///< Slider range for card size adjustment. + cardSizeSlider->setValue(defaultValue); ///< Initial slider value. cardSizeLayout->addWidget(cardSizeLabel); cardSizeLayout->addWidget(cardSizeSlider); @@ -73,13 +74,26 @@ QSlider *CardSizeWidget::getSlider() const void CardSizeWidget::enableCtrlScrollResize(QWidget *host) { - host->installEventFilter(this); - - const auto scrollAreas = host->findChildren(); - for (QScrollArea *scrollArea : scrollAreas) { - if (QWidget *content = scrollArea->widget()) { - content->installEventFilter(this); + const auto watchScrollArea = [this](QAbstractScrollArea *scrollArea) { + if (scrollArea == nullptr) { + return; } + if (QWidget *viewport = scrollArea->viewport()) { + viewport->installEventFilter(this); + } + if (auto *contentScrollArea = qobject_cast(scrollArea)) { + if (QWidget *content = contentScrollArea->widget()) { + content->installEventFilter(this); + } + } + }; + + host->installEventFilter(this); + watchScrollArea(qobject_cast(host)); + + const auto scrollAreas = host->findChildren(); + for (QAbstractScrollArea *scrollArea : scrollAreas) { + watchScrollArea(scrollArea); } } diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.h b/cockatrice/src/interface/widgets/cards/card_size_widget.h index 7de97a5e9..fced90d33 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.h @@ -24,6 +24,10 @@ class CardSizeWidget : public QWidget Q_OBJECT public: + /// Range of the card size slider, as a percentage of the base card width. + static constexpr int MIN_CARD_SIZE = 50; + static constexpr int MAX_CARD_SIZE = 250; + explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100); [[nodiscard]] QSlider *getSlider() const; @@ -31,8 +35,9 @@ public: * @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants. * * Installs this widget as an event filter on the given host widget. If the host contains a - * scroll area the filter is also installed on the scroll area's content widget, so the resize - * intercepts the wheel event before the scroll area would scroll the view. + * scroll area the filter is also installed on the scroll area's content widget, and on the + * viewport of every scroll area base (QListView, QTableView, ...), so the resize intercepts + * the wheel event before the scroll area would scroll the view. */ void enableCtrlScrollResize(QWidget *host); diff --git a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp new file mode 100644 index 000000000..7b0d526f9 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp @@ -0,0 +1,203 @@ +#include "card_database_visual_view.h" + +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_picture_delegate.h" +#include "../cards/card_size_widget.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/printing/exact_card.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardDatabaseVisualView::CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent) + : QListView(parent), model(_model) +{ + auto *cardDelegate = new CardPictureDelegate(model, this); + setItemDelegate(cardDelegate); + setModel(model); + + setViewMode(QListView::IconMode); + setFlow(QListView::LeftToRight); + setWrapping(true); + setResizeMode(QListView::Adjust); + setMovement(QListView::Static); + setUniformItemSizes(true); + setLayoutMode(QListView::SinglePass); + setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setFrameShape(QFrame::NoFrame); + setWordWrap(false); + + // QListView hands the delegate's item height to the scroll bar as its single + // step under ScrollPerPixel, so one wheel notch would jump wheelScrollLines() + // whole card rows. Give the bar a text line instead; the wheel handler below + // scales that line to the grid. A positive step also stops Qt from taking the + // item height back on the next layout. + verticalScrollBar()->setSingleStep(qMax(1, fontMetrics().height())); + + // The grid is a read-only surface: clicks are actions on a card, not a + // selection, and they must not steal focus from the search field. + setSelectionMode(QAbstractItemView::NoSelection); + setEditTriggers(QAbstractItemView::NoEditTriggers); + setFocusPolicy(Qt::NoFocus); + + // Hover drives the card preview. + setMouseTracking(true); + viewport()->setMouseTracking(true); + + connect(&CardPictureLoader::getInstance(), &CardPictureLoader::pixmapLoaded, this, + [this] { viewport()->update(); }); + + // A new search result starts at the top, like the widget-per-card grid did. + connect(model, &QAbstractItemModel::modelReset, this, [this] { + verticalScrollBar()->setValue(0); + // The rows under a stationary cursor changed with the reset: re-resolve the + // hover so the preview follows the grid, as enterEvent did per card widget. + hoveredRow = -1; + updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos())); + }); + + setCardSizeFactor(cardSizeFactor); +} + +void CardDatabaseVisualView::setCardSizeFactor(int factor) +{ + cardSizeFactor = qBound(CardSizeWidget::MIN_CARD_SIZE, factor, CardSizeWidget::MAX_CARD_SIZE); + + const QSize cardSize = CardInfoPictureWidget::sizeForScaleFactor(cardSizeFactor); + if (auto *cardDelegate = qobject_cast(itemDelegate())) { + cardDelegate->setCardSize(cardSize); + } + // The delegate's sizeHint does not emit sizeHintChanged, so the grid size is what + // makes a resize take effect. Keep the two in step. + setGridSize(cardSize + QSize(CardPictureDelegate::SPACING, CardPictureDelegate::SPACING)); +} + +void CardDatabaseVisualView::mousePressEvent(QMouseEvent *event) +{ + const QModelIndex index = indexAt(event->pos()); + if (index.isValid() && model != nullptr) { + const ExactCard card = model->cardAt(index.row()); + if (card) { + emit cardClicked(event, card); + event->accept(); + return; + } + } + + QListView::mousePressEvent(event); +} + +void CardDatabaseVisualView::mouseMoveEvent(QMouseEvent *event) +{ + QListView::mouseMoveEvent(event); + updateHoveredRow(event->pos()); +} + +bool CardDatabaseVisualView::viewportEvent(QEvent *event) +{ + // A per-card widget reported its hover from enterEvent, which also fires when the + // grid changes under a stationary cursor. A QListView only reports moves, so + // re-resolve the hover on enter and clear it on leave. + if (event->type() == QEvent::Enter) { + updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos())); + } else if (event->type() == QEvent::Leave) { + hoveredRow = -1; + } + + return QListView::viewportEvent(event); +} + +void CardDatabaseVisualView::wheelEvent(QWheelEvent *event) +{ + // Ctrl+wheel is the card-size gesture and is filtered out before it reaches + // the view; any other modifier keeps QAbstractSlider's own page scrolling. + if (event->modifiers() != Qt::NoModifier) { + QListView::wheelEvent(event); + return; + } + + QScrollBar *bar = verticalScrollBar(); + if (!bar->isVisible()) { + event->ignore(); + return; + } + + // Touchpads and high-resolution wheels already report pixels: follow them. + if (!event->pixelDelta().isNull()) { + bar->setValue(bar->value() - event->pixelDelta().y()); + event->accept(); + return; + } + + if (event->angleDelta().y() == 0) { + QListView::wheelEvent(event); + return; + } + + // Accumulate wheel deltas smaller than a notch, like QAbstractSlider does, so + // a fine-grained wheel still moves. + wheelRemainder += static_cast(event->angleDelta().y()) / 120.0; + const int notches = static_cast(wheelRemainder); + if (notches == 0) { + event->accept(); + return; + } + wheelRemainder -= notches; + + const int pixels = -notches * wheelStep(); + bar->setValue(qBound(bar->minimum(), bar->value() + pixels, bar->maximum())); + event->accept(); +} + +int CardDatabaseVisualView::wheelStep() const +{ + // A card row is far too coarse for one wheel line, and a text line is too + // fine, so a line is an eighth of the grid cell. A notch then moves about a + // third of a card row at any card size, and the OS line count still applies. + const int lineHeight = qMax(1, gridSize().height() / 8); + return qMax(1, QApplication::wheelScrollLines()) * lineHeight; +} + +void CardDatabaseVisualView::updateHoveredRow(const QPoint &position) +{ + // indexAt() also answers for points outside the viewport, so a cursor parked + // below the grid (on the size slider, say) would resolve to the row just under + // the visible area. Only a point inside the viewport can hover a card. + if (model == nullptr || !viewport()->rect().contains(position)) { + return; + } + + const QModelIndex index = indexAt(position); + const int row = index.isValid() ? index.row() : -1; + if (row == hoveredRow) { + return; + } + + hoveredRow = row; + if (row >= 0) { + const ExactCard card = model->cardAt(row); + if (card) { + emit cardHovered(card); + } + } +} + +void CardDatabaseVisualView::leaveEvent(QEvent *event) +{ + hoveredRow = -1; + QListView::leaveEvent(event); +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h new file mode 100644 index 000000000..5f004484d --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h @@ -0,0 +1,67 @@ +/** + * @file card_database_visual_view.h + * @ingroup VisualCardDatabaseWidgets + * @brief Icon-mode view of the card database, one card picture per item. + */ + +#ifndef CARD_DATABASE_VISUAL_VIEW_H +#define CARD_DATABASE_VISUAL_VIEW_H + +#include +#include +#include + +class CardDatabaseVisualDisplayModel; +class ExactCard; +class QEvent; +class QMouseEvent; +class QPoint; +class QWheelEvent; + +/** + * @brief A wrapping grid of card pictures. + * + * The view virtualizes painting: it only asks the delegate for the items that are + * on screen, so the whole card database can sit in the model without a widget (or + * pixmap) per card. Clicks and hover are reported with the exact card under the + * cursor, which is what the deck editor needs to add, decrement or preview a card. + */ +class CardDatabaseVisualView : public QListView +{ + Q_OBJECT + +public: + explicit CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent = nullptr); + + /** + * @brief Sets the card size as a percentage of the base card width. + * @param factor Percentage in the range of the card size slider. + */ + void setCardSizeFactor(int factor); + +signals: + /** @brief A card was pressed. The event is valid for the duration of the emission. */ + void cardClicked(QMouseEvent *event, const ExactCard &card); + /** @brief The cursor moved onto a different card. */ + void cardHovered(const ExactCard &card); + +protected: + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void leaveEvent(QEvent *event) override; + bool viewportEvent(QEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + void updateHoveredRow(const QPoint &position); + /// Pixels one wheel notch moves, scaled to the grid and the OS line count. + [[nodiscard]] int wheelStep() const; + + CardDatabaseVisualDisplayModel *model; + /// Fraction of a line left over from a wheel event smaller than one notch. + qreal wheelRemainder = 0.0; + int cardSizeFactor = 100; + int hoveredRow = -1; +}; + +#endif // CARD_DATABASE_VISUAL_VIEW_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 30e0af66b..6037418ad 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -5,40 +5,29 @@ #include "../../../filters/syntax_help.h" #include "../../key_signals.h" #include "../../pixel_map_generator.h" -#include "../cards/card_info_picture_widget.h" -#include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/card_size_widget.h" #include "../deck_editor/card_database_view.h" #include "../general/layout_containers/flow_widget.h" #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" +#include "card_database_visual_display_model.h" +#include "card_database_visual_view.h" #include "deck_list_model.h" #include "libcockatrice/card/card_localization.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/card/printing/exact_card.h" -#include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/filters/filter_card.h" #include "libcockatrice/filters/filter_tree.h" #include "visual_database_display_color_filter_widget.h" #include "visual_database_display_filter_toolbar_widget.h" -#include #include -#include #include #include #include -#include -#include -#include -#include -#include #include -#include -#include -#include #include #include #include @@ -48,14 +37,11 @@ #include #include #include -#include -#include #include #include #include #include #include -#include class QMouseEvent; @@ -64,11 +50,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, DeckListModel *deckListModel) : QWidget(parent) { - debounceTimer = new QTimer(this); - debounceTimer->setSingleShot(true); // Ensure it only fires once after the timeout - - connect(debounceTimer, &QTimer::timeout, this, &VisualDatabaseDisplayWidget::onSearchModelChanged); - // Create display model databaseDisplayModel = new CardDatabaseDisplayModel(this); databaseDisplayModel->setObjectName("databaseDisplayModel"); @@ -86,23 +67,12 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, applyCardSearchLanguage); - cards = new QList; - connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this, - &VisualDatabaseDisplayWidget::modelDirty); - // Set up main layout and widgets setMinimumSize(0, 0); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); mainLayout = QtUtils::makeLayout(this, 0, 1); setLayout(mainLayout); - flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded); - cardSizeWidget = new CardSizeWidget(this, flowWidget, - SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), - &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); - cardSizeWidget->enableCtrlScrollResize(flowWidget); - searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); searchEdit = new SearchLineEdit(); @@ -159,6 +129,25 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(databaseView, &CardDatabaseView::relatedCardClicked, this, &VisualDatabaseDisplayWidget::onRelatedCardClicked); + // The visual grid is a real view on a flattened model: one row per displayed + // card picture, painted on demand. + visualDisplayModel = new CardDatabaseVisualDisplayModel(databaseDisplayModel, CardDatabaseManager::query(), this); + + cardView = new CardDatabaseVisualView(visualDisplayModel, this); + cardView->setObjectName("visualCardView"); + connect(cardView, &CardDatabaseVisualView::cardClicked, this, + &VisualDatabaseDisplayWidget::cardClickedDatabaseDisplay); + connect(cardView, &CardDatabaseVisualView::cardHovered, this, + &VisualDatabaseDisplayWidget::cardHoveredDatabaseDisplay); + + const int cardSize = SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize(); + cardSizeWidget = new CardSizeWidget(this, nullptr, cardSize); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); + connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, cardView, &CardDatabaseVisualView::setCardSizeFactor); + cardView->setCardSizeFactor(cardSize); + cardSizeWidget->enableCtrlScrollResize(cardView); + colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel); filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel); @@ -190,8 +179,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseLoadIndicator->setVisible(false); } - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); }); retranslateUi(); } @@ -210,7 +197,7 @@ void VisualDatabaseDisplayWidget::initialize() mainLayout->addWidget(databaseView); - mainLayout->addWidget(flowWidget); + mainLayout->addWidget(cardView); mainLayout->addWidget(cardSizeWidget); @@ -241,8 +228,6 @@ void VisualDatabaseDisplayWidget::initializeFilters() databaseDisplayModel->setFilterTree(filterModel->filterTree()); - QTimer::singleShot(5000, this, [this] { loadCurrentPage(); }); - retranslateUi(); }); } @@ -278,71 +263,36 @@ void VisualDatabaseDisplayWidget::updateDisplayModeButton() displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual")); } -void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) -{ - QWidget::resizeEvent(event); - loadCurrentPage(); -} - void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked) { updateDisplayModeButton(); if (checked) { // Table mode - flowWidget->setVisible(false); + cardView->setVisible(false); cardSizeWidget->setVisible(false); databaseView->setItemDelegate(new QStyledItemDelegate(databaseView)); databaseView->setVisible(true); } else { // Visual mode - flowWidget->setVisible(true); + cardView->setVisible(true); cardSizeWidget->setVisible(true); databaseView->setVisible(false); - populateCards(); } } -void VisualDatabaseDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) -{ - emit cardClickedDatabaseDisplay(event, card); -} - -void VisualDatabaseDisplayWidget::onHover(const ExactCard &hoveredCard) -{ - emit cardHoveredDatabaseDisplay(hoveredCard); -} - -void VisualDatabaseDisplayWidget::addCardToDisplay(const ExactCard &cardToAdd) -{ - cards->append(cardToAdd); - auto *display = new CardInfoPictureWithTextOverlayWidget(flowWidget, false); - display->setScaleFactor(cardSizeWidget->getSlider()->value()); - display->setCard(cardToAdd); - flowWidget->addWidget(display); - connect(display, &CardInfoPictureWithTextOverlayWidget::cardClicked, this, &VisualDatabaseDisplayWidget::onClick); - connect(display, &CardInfoPictureWithTextOverlayWidget::hoveredOnCard, this, &VisualDatabaseDisplayWidget::onHover); - connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, display, &CardInfoPictureWidget::setScaleFactor); -} - bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const { return !displayModeButton->isChecked(); } +QStringList VisualDatabaseDisplayWidget::collectSetFilterTerms() const +{ + return filterModel->getFilterTermsOfType(CardFilter::AttrSet); +} + void VisualDatabaseDisplayWidget::onSearchModelChanged() { - if (flowWidget->isVisible()) { - // Clear the current page and prepare for new data - flowWidget->clearLayout(); // Clear existing cards - cards->clear(); // Clear the card list - // Reset scrollbar position to the top after loading new cards - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - scrollBar->setValue(0); // Reset scrollbar to top - - currentPage = 0; - loadCurrentPage(); - qCDebug(VisualDatabaseDisplayLog) << "Search model changed"; - } + visualDisplayModel->setSetFilterTerms(collectSetFilterTerms()); } void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName) @@ -368,108 +318,3 @@ void VisualDatabaseDisplayWidget::onRelatedCardClicked(const QString &relatedCar ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard}); emit cardInfoRequested(exactCard); } - -bool VisualDatabaseDisplayWidget::nearEndOfPage() const -{ - if (!flowWidget->isVisible()) { - return false; - } - - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - if (scrollBar->value() + scrollBar->pageStep() * 2 < scrollBar->maximum()) { - return false; // there is at least two pages of space to scroll remaining - } - - return true; -} - -void VisualDatabaseDisplayWidget::loadCurrentPage() -{ - // Ensure only the initial page is loaded - if (currentPage == 0) { - if (!initialLoadScheduled) { - initialLoadScheduled = true; - qCDebug(VisualDatabaseDisplayLog) << "Loading the first page"; - // Defer the first page so the tab switch stays responsive. The card - // grid builds one event loop turn later. This also applies to - // search-driven reloads, which reset currentPage back to 0. - QTimer::singleShot(0, this, [this] { - initialLoadScheduled = false; - populateCards(); - }); - } - } else if (nearEndOfPage()) { - // If not the first page, just load the next page and append to the flow widget - loadNextPage(); - } -} - -void VisualDatabaseDisplayWidget::populateCards() -{ - cards->clear(); - loadNextPage(); -} - -void VisualDatabaseDisplayWidget::loadNextPage() -{ - // Calculate the start and end indices for the next page - int rowCount = databaseDisplayModel->rowCount(); - int start = currentPage * cardsPerPage; - int end = qMin(start + cardsPerPage, rowCount); - - // Load the next page of cards and add them to the flow widget - flowWidget->setUpdatesEnabled(false); - loadPage(start, end); - flowWidget->setUpdatesEnabled(true); -} - -void VisualDatabaseDisplayWidget::loadPage(int start, int end) -{ - QList setFilters = filterModel->getFiltersOfType(CardFilter::AttrSet); - for (int row = start; row < end; ++row) { - qCDebug(VisualDatabaseDisplayLog) << "Adding " << row; - QModelIndex index = databaseDisplayModel->index(row, CardDatabaseModel::NameColumn); - QVariant name = databaseDisplayModel->data(index, Qt::DisplayRole); - qCDebug(VisualDatabaseDisplayLog) << name.toString(); - - if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) { - if (!setFilters.empty()) { - const auto &setMap = info->getSets(); - for (const CardFilter *setFilter : setFilters) { - const auto it = setMap.constFind(setFilter->term()); - if (it != setMap.constEnd()) { - for (const PrintingInfo &printing : it.value()) { - addCardToDisplay(ExactCard(info, printing)); - } - } - } - } else { - addCardToDisplay(CardDatabaseManager::query()->getPreferredCard(info)); - } - } else { - qCDebug(VisualDatabaseDisplayLog) << "Card not found in database!"; - } - } - currentPage++; -} - -void VisualDatabaseDisplayWidget::modelDirty() const -{ - debounceTimer->start(debounceTime); -} - -void VisualDatabaseDisplayWidget::sortCardList(const QStringList &properties, - Qt::SortOrder order = Qt::AscendingOrder) const -{ - CardInfoComparator comparator(properties, order); - std::sort(cards->begin(), cards->end(), [comparator](const ExactCard &a, const ExactCard &b) { - return comparator(a.getCardPtr(), b.getCardPtr()); - }); -} - -void VisualDatabaseDisplayWidget::databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight) -{ - (void)topLeft; - (void)bottomRight; - qCDebug(VisualDatabaseDisplayLog) << "Database Data changed"; -} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 15adca214..6148670a3 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -9,8 +9,6 @@ #include "libcockatrice/card/card_info.h" -#include -#include #include #include #include @@ -21,26 +19,21 @@ class CardDatabaseDisplayModel; class CardDatabaseModel; class CardDatabaseView; +class CardDatabaseVisualDisplayModel; +class CardDatabaseVisualView; class CardSizeWidget; -class DeckList; class DeckListModel; class ExactCard; class FilterTreeModel; class FlowWidget; -class OverlapControlWidget; class QLabel; -class QModelIndex; +class QMouseEvent; class QPushButton; -class QScrollArea; -class QTimer; class QToolButton; class QVBoxLayout; class SearchLineEdit; class VisualDatabaseDisplayColorFilterWidget; class VisualDatabaseDisplayFilterToolbarWidget; -template class QList; - -inline Q_LOGGING_CATEGORY(VisualDatabaseDisplayLog, "visual_database_display"); class VisualDatabaseDisplayWidget : public QWidget { @@ -52,14 +45,6 @@ public: DeckListModel *deckListModel = nullptr); void retranslateUi(); - void adjustCardsPerPage(); - void populateCards(); - void loadPage(int start, int end); - void loadNextPage(); - void loadCurrentPage(); - void sortCardList(const QStringList &properties, Qt::SortOrder order) const; - void setDeckList(const DeckList &new_deck_list_model); - /** * @brief Sets the callback used to create a custom zone from the add-to-zone menu. * The callback returns the name of the created zone, or an empty string if creation was cancelled. @@ -87,6 +72,13 @@ public: bool isVisualDisplayMode() const; public slots: + /** + * @brief Pushes the active set filters to the visual grid. + * + * The grid's rows follow the display model's filter and sort signals on their + * own; this only forwards the set filters, which decide which printings the + * grid shows. + */ void onSearchModelChanged(); signals: @@ -101,11 +93,6 @@ signals: protected slots: void initialize(); - void onClick(QMouseEvent *event, const ExactCard &card); - void onHover(const ExactCard &hoveredCard); - void addCardToDisplay(const ExactCard &cardToAdd); - void databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight); - void modelDirty() const; void onDisplayModeChanged(bool checked); void onSelectedCardChanged(const QString &cardName); @@ -125,31 +112,21 @@ private: QToolButton *clearFilterWidget; VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; + CardDatabaseVisualDisplayModel *visualDisplayModel; CardDatabaseView *databaseView; + CardDatabaseVisualView *cardView; std::function newZoneCreator; - QList *cards; QVBoxLayout *mainLayout; - QScrollArea *scrollArea; - FlowWidget *flowWidget; - QWidget *overlapCategories; - QVBoxLayout *overlapCategoriesLayout; - OverlapControlWidget *overlapControlWidget; CardSizeWidget *cardSizeWidget; - QTimer *debounceTimer; - int debounceTime = 300; // in Ms - int currentPage = 0; // Current page index - int cardsPerPage = 100; // Number of cards per page bool filtersInitialized = false; - bool initialLoadScheduled = false; void initializeFilters(); void highlightAllSearchEdit(); void updateDisplayModeButton(); - bool nearEndOfPage() const; + [[nodiscard]] QStringList collectSetFilterTerms() const; protected: - void resizeEvent(QResizeEvent *event) override; void showEvent(QShowEvent *event) override; }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index e6a37ee40..dbeffd18f 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -54,7 +54,7 @@ private: enum Attr a; public: - CardFilter(QString &term, Type type, Attr attr) : trm(term), t(type), a(attr) + CardFilter(const QString &term, Type type, Attr attr) : trm(term), t(type), a(attr) { } diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index 5cf3caa52..22a04cd32 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -1,6 +1,7 @@ set(HEADERS card_database_model.h card_database_display_model.h + card_database_visual_display_model.h card/card_completer_proxy_model.h card/card_search_model.h card_set/card_sets_model.h @@ -13,6 +14,7 @@ add_library( ${HEADERS} card_database_model.cpp card_database_display_model.cpp + card_database_visual_display_model.cpp card/card_completer_proxy_model.cpp card/card_search_model.cpp card_set/card_sets_model.cpp diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 6ed9d8827..9ec0afd9c 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -21,7 +21,7 @@ class QObject; CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) - : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), + : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), cardModel(nullptr), collator(NaturalSort::makeNaturalSortCollator()) { filterTree = nullptr; @@ -37,6 +37,7 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) { + cardModel = qobject_cast(model); QSortFilterProxyModel::setSourceModel(model); connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); }); @@ -46,6 +47,25 @@ void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); }); } +QVariant CardDatabaseDisplayModel::data(const QModelIndex &index, int role) const +{ + if (role == CardInfoRole) { + return QVariant::fromValue(cardInfoAt(index)); + } + + return QSortFilterProxyModel::data(index, role); +} + +CardInfoPtr CardDatabaseDisplayModel::cardInfoAt(const QModelIndex &index) const +{ + if (!index.isValid() || cardModel == nullptr) { + return {}; + } + + const QModelIndex sourceIndex = mapToSource(index); + return sourceIndex.isValid() ? cardModel->getCard(sourceIndex.row()) : CardInfoPtr(); +} + QMap CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'}, {L'”', L'\"'}, {L'‘', L'\''}, diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index be074bba5..3a9bbc6ab 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -21,6 +21,7 @@ #include #include +class CardDatabaseModel; class FilterTree; class QObject; template class QMap; @@ -36,12 +37,25 @@ public: ShowAll }; + enum Role + { + /** + * @brief The CardInfoPtr behind a proxy row. + * + * Saves views from looking the card up again through its name, and keeps + * them from depending on the source model's column layout. + */ + CardInfoRole = Qt::UserRole + 1 + }; + private: FilterBool isToken; QString cardName, cardText; QSet cardNameSet, cardTypes, cardColors; FilterTree *filterTree; FilterString *filterString; + /// The source model, kept typed so CardInfoRole does not cast per row. + CardDatabaseModel *cardModel; QTimer dirtyTimer; CardSearchLanguage searchLanguage; QString searchText; @@ -84,6 +98,16 @@ public: dirtyTimer.start(20); } void clearFilterAll(); + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Returns the card behind a proxy row, without the QVariant wrapper of CardInfoRole. + * + * The visual grid calls this for every row on each rebuild; CardInfoRole stays the + * generic way in for consumers that only see the model through data(). + */ + [[nodiscard]] CardInfoPtr cardInfoAt(const QModelIndex &index) const; + signals: void modelDirty(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp new file mode 100644 index 000000000..f6a3a925c --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp @@ -0,0 +1,123 @@ +#include "card_database_visual_display_model.h" + +#include "card_database_display_model.h" +#include "libcockatrice/card/database/card_database_querier.h" + +#include +#include +#include +#include + +/// Coalesces the bursts of proxy signals a filter, sort or database load produces. +static constexpr int REBUILD_COALESCE_MS = 30; + +CardDatabaseVisualDisplayModel::CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel, + CardDatabaseQuerier *_querier, + QObject *parent) + : QAbstractListModel(parent), displayModel(_displayModel), querier(_querier) +{ + rebuildTimer.setSingleShot(true); + rebuildTimer.setInterval(REBUILD_COALESCE_MS); + connect(&rebuildTimer, &QTimer::timeout, this, &CardDatabaseVisualDisplayModel::rebuild); + + if (displayModel == nullptr) { + return; + } + + connect(displayModel, &QAbstractItemModel::modelReset, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::layoutChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::rowsInserted, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::rowsRemoved, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::dataChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); +} + +void CardDatabaseVisualDisplayModel::setSetFilterTerms(const QStringList &terms) +{ + if (setFilterTerms == terms) { + return; + } + + setFilterTerms = terms; + refresh(); +} + +void CardDatabaseVisualDisplayModel::refresh() +{ + rebuildTimer.stop(); + rebuild(); +} + +void CardDatabaseVisualDisplayModel::scheduleRebuild() +{ + rebuildTimer.start(); +} + +void CardDatabaseVisualDisplayModel::rebuild() +{ + beginResetModel(); + entries.clear(); + + if (displayModel != nullptr) { + const int proxyRows = displayModel->rowCount(); + entries.reserve(proxyRows); + for (int row = 0; row < proxyRows; ++row) { + const CardInfoPtr info = displayModel->cardInfoAt(displayModel->index(row, 0)); + if (info) { + appendPrintingsFor(info); + } + } + } + + endResetModel(); +} + +void CardDatabaseVisualDisplayModel::appendPrintingsFor(const CardInfoPtr &info) +{ + if (setFilterTerms.isEmpty()) { + entries.append({info, PrintingInfo()}); + return; + } + + const auto &printingsBySet = info->getSets(); + for (const QString &term : setFilterTerms) { + const auto printings = printingsBySet.constFind(term); + if (printings == printingsBySet.constEnd()) { + continue; + } + for (const PrintingInfo &printing : printings.value()) { + entries.append({info, printing}); + } + } +} + +int CardDatabaseVisualDisplayModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : static_cast(entries.size()); +} + +QVariant CardDatabaseVisualDisplayModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= entries.size()) { + return {}; + } + + if (role == Qt::DisplayRole || role == Qt::AccessibleTextRole) { + return cardAt(index.row()).getName(); + } + + return {}; +} + +ExactCard CardDatabaseVisualDisplayModel::cardAt(int row) const +{ + if (row < 0 || row >= entries.size()) { + return {}; + } + + const Entry &entry = entries.at(row); + if (entry.printing.isEmpty() && setFilterTerms.isEmpty()) { + return querier != nullptr ? querier->getPreferredCard(entry.info) : ExactCard(entry.info); + } + + return ExactCard(entry.info, entry.printing); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h new file mode 100644 index 000000000..1303b6464 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h @@ -0,0 +1,90 @@ +/** + * @file card_database_visual_display_model.h + * @ingroup CardDatabaseModels + * @brief A list model of the exact cards a visual card grid paints. + * + * The filtered and sorted CardDatabaseDisplayModel holds one row per card name. + * A visual grid shows one picture per *printing* instead, so this model flattens + * the proxy rows into ExactCards: the preferred printing per card normally, and + * every printing of the selected sets while set filters are active. + */ + +#ifndef COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H +#define COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H + +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" + +#include +#include +#include +#include +#include +#include +#include + +class CardDatabaseDisplayModel; +class CardDatabaseQuerier; + +class CardDatabaseVisualDisplayModel : public QAbstractListModel +{ + Q_OBJECT + +public: + /** + * @brief Flattens _displayModel into the cards a visual grid displays. + * @param _displayModel The filtered and sorted proxy model of the card database. + * @param _querier Resolves the preferred printing of a card. + * @param parent Parent object. + */ + explicit CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel, + CardDatabaseQuerier *_querier, + QObject *parent = nullptr); + + /** + * @brief Sets the set shorthand names of the active set filters. + * + * With no terms every card is shown with its preferred printing. With terms + * every printing belonging to one of the named sets is shown instead, in term + * order. Rebuilds immediately when the terms change. + */ + void setSetFilterTerms(const QStringList &terms); + + /** + * @brief Rebuilds the row list from the proxy model right away. + * + * Proxy model changes normally rebuild on a short coalescing timer; call this + * when the caller needs the new rows before returning. + */ + void refresh(); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Returns the card painted at @p row, with its preferred printing resolved. + * @param row Row in this model, not in the proxy model. + */ + [[nodiscard]] ExactCard cardAt(int row) const; + +private: + struct Entry + { + CardInfoPtr info; + /// Empty when the entry follows the preferred printing rather than a set filter. + PrintingInfo printing; + }; + + void scheduleRebuild(); + void rebuild(); + void appendPrintingsFor(const CardInfoPtr &info); + + QPointer displayModel; + CardDatabaseQuerier *querier; + QStringList setFilterTerms; + QList entries; + QTimer rebuildTimer; +}; + +#endif // COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 22171a9e8..7d83640a1 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -35,6 +35,21 @@ if(WITH_ORACLE OR WITH_CLIENT) LIBS libcockatrice_filters ) + + # ------------------------ + # Visual Display Model Test + # ------------------------ + cockatrice_add_test( + NAME + visual_display_model_test + SOURCES + ${VERSION_STRING_CPP} + visual_display_model_test.cpp + mocks.cpp + LIBS + libcockatrice_card + libcockatrice_models + ) endif() # ------------------------ diff --git a/tests/carddatabase/visual_display_model_test.cpp b/tests/carddatabase/visual_display_model_test.cpp new file mode 100644 index 000000000..5fa554bb3 --- /dev/null +++ b/tests/carddatabase/visual_display_model_test.cpp @@ -0,0 +1,188 @@ +/** + * @file visual_display_model_test.cpp + * @brief Tests for the flattened model behind the visual card database grid. + */ + +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/noop_card_preference_provider.h" +#include "libcockatrice/interfaces/noop_card_set_priority_controller.h" +#include "test_card_database_path_provider.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// GTest's main() creates no QCoreApplication, which QTimer needs for its event loop. +const QCoreApplication &application() +{ + static int argc = 1; + static char executable[] = "visual_display_model_test"; + static char *argv[] = {executable, nullptr}; + static const QCoreApplication app(argc, argv); + return app; +} + +/// Lets the rebuild timer fire, as it would in the running client. +void waitForRebuild() +{ + QEventLoop loop; + QTimer::singleShot(100, &loop, &QEventLoop::quit); + loop.exec(); +} + +class CardDatabaseVisualDisplayModelTest : public ::testing::Test +{ +protected: + void SetUp() override + { + (void)application(); + + db = std::make_unique(nullptr, new NoopCardPreferenceProvider(), + new TestCardDatabasePathProvider(), new NoopCardSetPriorityController()); + db->loadCardDatabases(); + + cardModel = std::make_unique(db.get(), false); + displayModel = std::make_unique(); + displayModel->setSourceModel(cardModel.get()); + visualModel = std::make_unique(displayModel.get(), db->query()); + visualModel->refresh(); + } + + /** + * @brief Builds a card with @p printingCount printings in each named set. + * @param printingCountPerSet Set short name to the number of printings in that set. + */ + [[nodiscard]] CardInfoPtr makeCard(const QString &name, const QMap &printingCountPerSet) + { + SetToPrintingsMap setsInfo; + for (auto it = printingCountPerSet.constBegin(); it != printingCountPerSet.constEnd(); ++it) { + CardSetPtr set = CardSet::newInstance(&setController, it.key(), it.key() + " test set", "expansion", + QDate(), CardSet::PriorityPrimary); + for (int printing = 0; printing < it.value(); ++printing) { + QHash properties; + properties["uuid"] = QString("%1-%2-%3").arg(it.key(), name).arg(printing); + setsInfo[it.key()].append(PrintingInfo(set, LazyPropertiesHash(properties))); + } + } + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + return CardInfo::newInstance(name, "", false, {}, {}, {}, setsInfo, attributes); + } + + NoopCardSetPriorityController setController; + std::unique_ptr db; + std::unique_ptr cardModel; + std::unique_ptr displayModel; + std::unique_ptr visualModel; +}; + +TEST_F(CardDatabaseVisualDisplayModelTest, ExposesTheCardInfoBehindAProxyRow) +{ + ASSERT_GT(displayModel->rowCount(), 0); + + const QVariant info = displayModel->data(displayModel->index(0, CardDatabaseModel::NameColumn), + CardDatabaseDisplayModel::CardInfoRole); + ASSERT_TRUE(info.isValid()); + EXPECT_FALSE(info.value().isNull()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, FlattensOneRowPerProxyCardWithoutSetFilters) +{ + ASSERT_GT(displayModel->rowCount(), 0); + EXPECT_EQ(displayModel->rowCount(), visualModel->rowCount()); + + for (int row = 0; row < visualModel->rowCount(); ++row) { + const ExactCard card = visualModel->cardAt(row); + ASSERT_TRUE(card) << "row " << row; + EXPECT_EQ( + displayModel->data(displayModel->index(row, CardDatabaseModel::NameColumn), Qt::DisplayRole).toString(), + card.getName()) + << "row " << row; + } +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ShowsOneRowPerCardWhenNoSetIsFiltered) +{ + const int rowsBefore = visualModel->rowCount(); + + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + + EXPECT_EQ(rowsBefore + 1, visualModel->rowCount()); + EXPECT_EQ("Multi", visualModel->cardAt(visualModel->rowCount() - 1).getName()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ShowsEveryPrintingOfAFilteredSet) +{ + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + + visualModel->setSetFilterTerms({"TST"}); + + ASSERT_EQ(2, visualModel->rowCount()); + for (int row = 0; row < visualModel->rowCount(); ++row) { + const ExactCard card = visualModel->cardAt(row); + ASSERT_TRUE(card) << "row " << row; + EXPECT_EQ("Multi", card.getName()); + ASSERT_FALSE(card.getPrinting().isEmpty()) << "row " << row; + EXPECT_EQ("TST", card.getPrinting().getSet()->getShortName()) << "row " << row; + } + EXPECT_NE(visualModel->cardAt(0).getPrinting(), visualModel->cardAt(1).getPrinting()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, HidesCardsWithoutAPrintingInTheFilteredSets) +{ + visualModel->setSetFilterTerms({"NO SUCH SET"}); + + EXPECT_EQ(0, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, RestoresOneRowPerCardWhenTheSetTermsClear) +{ + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + const int rowsBefore = visualModel->rowCount(); + + visualModel->setSetFilterTerms({"TST"}); + ASSERT_EQ(2, visualModel->rowCount()); + + visualModel->setSetFilterTerms({}); + EXPECT_EQ(rowsBefore, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, RebuildsByItselfWhenTheProxyGainsARow) +{ + const int rowsBefore = visualModel->rowCount(); + + db->addCard(makeCard("Late", {{"TST", 1}})); + waitForRebuild(); + + EXPECT_EQ(rowsBefore + 1, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ReportsTheCardNameForDisplayAndAccessibleText) +{ + ASSERT_GT(visualModel->rowCount(), 0); + const QString expected = visualModel->cardAt(0).getName(); + + EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::DisplayRole).toString()); + EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::AccessibleTextRole).toString()); + EXPECT_FALSE(visualModel->data(QModelIndex(), Qt::DisplayRole).isValid()); +} +} // namespace diff --git a/vcpkg b/vcpkg index 9eec002df..9e593bb18 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 +Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d From 62e590d3005a0f8ca31ef45e97a9923a2552b085 Mon Sep 17 00:00:00 2001 From: RickyRister <42636155+RickyRister@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:37:05 -0700 Subject: [PATCH 186/247] [ServerGame] Refactor out spectator/judge bools into RoleInfo (#7761) --- .../network/server/remote/CMakeLists.txt | 1 + .../network/server/remote/game/role_info.h | 13 +++++++ .../game/server_abstract_participant.cpp | 22 +++++------ .../remote/game/server_abstract_participant.h | 10 ++--- .../remote/game/server_abstract_player.cpp | 13 +++---- .../server/remote/game/server_game.cpp | 39 +++++++++++-------- .../network/server/remote/game/server_game.h | 7 ++-- .../server/remote/game/server_player.cpp | 6 +-- .../server/remote/game/server_spectator.cpp | 3 +- .../server/remote/server_protocolhandler.cpp | 3 +- .../network/server/remote/server_room.cpp | 9 +++-- tests/server_game_join_test.cpp | 31 ++++++++------- 12 files changed, 89 insertions(+), 68 deletions(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 8586a7bc8..181218e1a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -8,6 +8,7 @@ set(HEADERS game/server_cardzone.h game/server_counter.h game/game_config.h + game/role_info.h game/server_deck_validation_strategy.h game/server_game.h game/server_game_lifecycle_strategy.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h b/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h new file mode 100644 index 000000000..ef8692361 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h @@ -0,0 +1,13 @@ +#ifndef COCKATRICE_ROLE_INFO_H +#define COCKATRICE_ROLE_INFO_H + +/** + * @brief Represents information about a participant's roles. + */ +struct RoleInfo +{ + bool isJudge = false; ///< Whether the participant joined as a judge + bool isSpectator = false; ///< Whether the participant joined as a spectator +}; + +#endif // COCKATRICE_ROLE_INFO_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index 5b8d901bb..ff8ea14f3 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -61,10 +61,10 @@ Server_AbstractParticipant::Server_AbstractParticipant(Server_Game *_game, int _playerId, const ServerInfo_User &_userInfo, - bool _judge, + const RoleInfo &_roleInfo, Server_AbstractUserInterface *_userInterface) : ServerInfo_User_Container(_userInfo), game(_game), userInterface(_userInterface), pingTime(0), - playerId(_playerId), judge(_judge) + playerId(_playerId), roleInfo(_roleInfo) { } @@ -98,8 +98,8 @@ void Server_AbstractParticipant::getProperties(ServerInfo_PlayerProperties &resu if (withUserInfo) { copyUserInfo(*(result.mutable_user_info()), true); } - result.set_spectator(spectator); - result.set_judge(judge); + result.set_spectator(isSpectator()); + result.set_judge(isJudge()); result.set_ping_seconds(pingTime); getPlayerProperties(result); } @@ -176,7 +176,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdUnconcede(const Command_Un Response::ResponseCode Server_AbstractParticipant::cmdJudge(const Command_Judge &cmd, ResponseContainer &rc, GameEventStorage &ges) { - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -204,7 +204,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdReadyStart(const Command_R Response::ResponseCode Server_AbstractParticipant::cmdGameSay(const Command_GameSay &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) { - if (spectator) { + if (isSpectator()) { /* Spectators can only talk if: * (a) the game creator allows it * (b) the spectator is a moderator/administrator @@ -365,7 +365,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdNextTurn(const Command_Nex return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -381,7 +381,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetActivePhase(const Comma return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -415,8 +415,8 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_ ResponseContainer & /*rc*/, GameEventStorage &ges) { - if (!judge) { - if (spectator) { + if (!isJudge()) { + if (isSpectator()) { return Response::RespFunctionNotAllowed; } @@ -577,7 +577,7 @@ void Server_AbstractParticipant::setUserInterface(Server_AbstractUserInterface * void Server_AbstractParticipant::disconnectClient() { bool isRegistered = userInfo->user_level() & ServerInfo_User::IsRegistered; - if (!isRegistered || spectator) { + if (!isRegistered || isSpectator()) { game->removeParticipant(this, Event_Leave::USER_DISCONNECTED); } else { setUserInterface(nullptr); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index 195da9b7f..b6d4a8025 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -2,6 +2,7 @@ #define ABSTRACT_PARTICIPANT_H #include "../serverinfo_user_container.h" +#include "role_info.h" #include "server_arrowtarget.h" #include @@ -61,8 +62,7 @@ protected: Server_AbstractUserInterface *userInterface; int pingTime; int playerId; - bool spectator; - bool judge; + RoleInfo roleInfo; virtual void getPlayerProperties(ServerInfo_PlayerProperties &result); mutable QMutex playerMutex; @@ -70,7 +70,7 @@ public: Server_AbstractParticipant(Server_Game *_game, int _playerId, const ServerInfo_User &_userInfo, - bool _judge, + const RoleInfo &_roleInfo, Server_AbstractUserInterface *_handler); ~Server_AbstractParticipant() override; virtual void prepareDestroy() @@ -91,11 +91,11 @@ public: } bool isSpectator() const { - return spectator; + return roleInfo.isSpectator; } bool isJudge() const { - return judge; + return roleInfo.isJudge; } Server_Game *getGame() const { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index c03f1e966..b5a620798 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -81,10 +81,9 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game, const ServerInfo_User &_userInfo, bool _judge, Server_AbstractUserInterface *_userInterface) - : Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface), conceded(false), deck(nullptr), - sideboardLocked(true), readyStart(false), nextCardId(0) + : Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge}, _userInterface), conceded(false), + deck(nullptr), sideboardLocked(true), readyStart(false), nextCardId(0) { - spectator = false; } Server_AbstractPlayer::~Server_AbstractPlayer() = default; @@ -295,7 +294,7 @@ Response::ResponseCode Server_AbstractPlayer::moveCard(GameEventStorage &ges, { // Disallow controller change to other zones than the table. if (((targetzone->getType() != ServerInfo_Zone::PublicZone) || !targetzone->hasCoords()) && - (startzone->getPlayer() != targetzone->getPlayer()) && !judge) { + (startzone->getPlayer() != targetzone->getPlayer()) && !isJudge()) { return Response::RespContextError; } @@ -808,7 +807,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine return Response::RespNameNotFound; } - if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !judge) { + if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !isJudge()) { return Response::RespContextError; } @@ -821,7 +820,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine return Response::RespNameNotFound; } - if ((startPlayer != this) && (targetPlayer != this) && !judge) { + if ((startPlayer != this) && (targetPlayer != this) && !isJudge()) { return Response::RespContextError; } @@ -1598,7 +1597,7 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo zone->addWritePermission(cmd.player_id()); } - if (judge) { + if (isJudge()) { ges.setOverwriteOwnership(true); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index b1863c91e..49db999f6 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -41,6 +41,7 @@ #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" +#include "role_info.h" #include "server_abstract_player.h" #include "server_abstractuserinterface.h" #include "server_arrow.h" @@ -479,9 +480,8 @@ void Server_Game::stopGameIfFinished() Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, const QString &_password, - bool spectator, - bool overrideRestrictions, - bool asJudge) + const RoleInfo &roleInfo, + bool overrideRestrictions) { Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface(); for (auto *participant : participants.values()) { @@ -490,11 +490,11 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, } } - if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { + if (roleInfo.isJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { return Response::RespUserLevelTooLow; } if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) { - if ((_password != password) && !(spectator && !spectatorsNeedPassword)) { + if ((_password != password) && !(roleInfo.isSpectator && !spectatorsNeedPassword)) { return Response::RespWrongPassword; } if (!(user->user_level() & ServerInfo_User::IsRegistered) && onlyRegistered) { @@ -510,13 +510,13 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, QString::fromStdString(user->name()))) { return Response::RespInIgnoreList; } - if (spectator) { + if (roleInfo.isSpectator) { if (!spectatorsAllowed) { return Response::RespSpectatorsNotAllowed; } } } - if (!spectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) { + if (!roleInfo.isSpectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) { return Response::RespGameFull; } @@ -537,19 +537,18 @@ bool Server_Game::containsUser(const QString &userName) const void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, ResponseContainer &rc, - bool spectator, - bool judge, + const RoleInfo &roleInfo, bool broadcastUpdate) { QMutexLocker locker(&gameMutex); Server_AbstractParticipant *newParticipant; - if (spectator) { + if (roleInfo.isSpectator) { newParticipant = new Server_Spectator(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), - judge, userInterface); + roleInfo.isJudge, userInterface); } else { - newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), judge, - userInterface); + newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), + roleInfo.isJudge, userInterface); } newParticipant->moveToThread(thread()); @@ -560,7 +559,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, const QString playerName = QString::fromStdString(newParticipant->getUserInfo()->name()); participants.insert(newParticipant->getPlayerId(), newParticipant); - if (spectator) { + if (roleInfo.isSpectator) { allSpectatorsEver.insert(playerName); } else { allPlayersEver.insert(playerName); @@ -579,7 +578,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, emit gameInfoChanged(gameInfo); } - if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !spectator) { + if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !roleInfo.isSpectator) { room->getServer()->addPersistentPlayer(playerName, room->getId(), gameId, newParticipant->getPlayerId()); } @@ -871,6 +870,13 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti } } +bool Server_Game::canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const +{ + return participant->getPlayerId() == privatePlayerId // same player + || participant->isJudge() // judges see everything + || (participant->isSpectator() && spectatorsSeeEverything); // spectators see everything +} + void Server_Game::sendGameEventContainer(GameEventContainer *cont, GameEventStorageItem::EventRecipients recipients, int privatePlayerId) @@ -879,8 +885,7 @@ void Server_Game::sendGameEventContainer(GameEventContainer *cont, cont->set_game_id(gameId); for (auto *participant : participants.values()) { - const bool playerPrivate = (participant->getPlayerId() == privatePlayerId) || participant->isJudge() || - (participant->isSpectator() && spectatorsSeeEverything); + const bool playerPrivate = canSeePrivateEventsFor(participant, privatePlayerId); if ((recipients.testFlag(GameEventStorageItem::SendToPrivate) && playerPrivate) || (recipients.testFlag(GameEventStorageItem::SendToOthers) && !playerPrivate)) { participant->sendGameEvent(*cont); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index c7f8d0929..363c0ca94 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -62,6 +62,7 @@ class Message; } // namespace protobuf } // namespace google struct GameConfig; +struct RoleInfo; class Server_Game : public QObject { @@ -109,6 +110,7 @@ private: bool omniscient, bool withUserInfo); void storeGameInformation(); + bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const; signals: void sigStartGameIfReady(bool override); void gameInfoChanged(ServerInfo_Game gameInfo); @@ -188,12 +190,11 @@ public: return shareDecklistsOnLoad; } Response::ResponseCode - checkJoin(ServerInfo_User *user, const QString &_password, bool spectator, bool overrideRestrictions, bool asJudge); + checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions); bool containsUser(const QString &userName) const; void addPlayer(Server_AbstractUserInterface *userInterface, ResponseContainer &rc, - bool spectator, - bool judge, + const RoleInfo &roleInfo, bool broadcastUpdate = true); void removeParticipant(Server_AbstractParticipant *participant, Event_Leave::LeaveReason reason); void removeArrowsRelatedToPlayer(GameEventStorage &ges, Server_AbstractPlayer *player); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index 949d04b4b..b40dae09b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -573,7 +573,7 @@ Server_Player::cmdNextTurn(const Command_NextTurn & /*cmd*/, ResponseContainer & return Response::RespGameNotStarted; } - if (conceded && !judge) { + if (conceded && !isJudge()) { return Response::RespContextError; } @@ -589,7 +589,7 @@ Response::ResponseCode Server_Player::cmdSetActivePhase(const Command_SetActiveP return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { if (conceded) { return Response::RespContextError; } @@ -623,7 +623,7 @@ Response::ResponseCode Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer &rc, GameEventStorage &ges) { - if (!judge && conceded) { + if (!isJudge() && conceded) { return Response::RespContextError; } return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp index d1c369949..164a4af5e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp @@ -11,7 +11,6 @@ Server_Spectator::Server_Spectator(Server_Game *_game, const ServerInfo_User &_userInfo, bool _judge, Server_AbstractUserInterface *_userInterface) - : Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface) + : Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge, .isSpectator = true}, _userInterface) { - spectator = true; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 39c1eb3e6..f9cadc9aa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1141,7 +1141,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room auto *game = new Server_Game(config, room); - game->addPlayer(this, rc, asSpectator, asJudge, false); + RoleInfo roleInfo{.isJudge = asJudge, .isSpectator = asSpectator}; + game->addPlayer(this, rc, roleInfo, false); room->addGame(game); return Response::RespOk; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 68b137cd4..78ef2b289 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -1,5 +1,6 @@ #include "server_room.h" +#include "game/role_info.h" #include "game/server_game.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_event.pb.h" @@ -372,11 +373,11 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam QMutexLocker gameLocker(&game->gameMutex); - Response::ResponseCode result = - game->checkJoin(userInterface->getUserInfo(), QString::fromStdString(cmd.password()), cmd.spectator(), - cmd.override_restrictions(), cmd.join_as_judge()); + RoleInfo roleInfo{.isJudge = cmd.join_as_judge(), .isSpectator = cmd.spectator()}; + Response::ResponseCode result = game->checkJoin( + userInterface->getUserInfo(), QString::fromStdString(cmd.password()), roleInfo, cmd.override_restrictions()); if (result == Response::RespOk) { - game->addPlayer(userInterface, rc, cmd.spectator(), cmd.join_as_judge()); + game->addPlayer(userInterface, rc, roleInfo); } return result; diff --git a/tests/server_game_join_test.cpp b/tests/server_game_join_test.cpp index 8a2ae8c45..93895ab5c 100644 --- a/tests/server_game_join_test.cpp +++ b/tests/server_game_join_test.cpp @@ -4,6 +4,7 @@ */ #include "game/game_config.h" +#include "game/role_info.h" #include "game/server_game.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "server.h" @@ -119,13 +120,13 @@ TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction) game = makeGame(true, false, false, true); // A plain user cannot override the password even with the override flag set. - EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&plainUser, "wrong", {}, true), Response::RespWrongPassword); // Moderators and judges may enter any game regardless of the password. - EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, "wrong", {}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {}, true), Response::RespOk); // Without the override flag judges are still subject to the password. - EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword); - EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isJudge = true}, false), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&judge, "secret", {.isJudge = true}, false), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) @@ -133,9 +134,9 @@ TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) game = makeGame(false, true, false, true); // Without the override flag the only-registered restriction still applies. - EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, false), Response::RespUserLevelTooLow); // An unregistered judge may enter when overriding restrictions. - EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) @@ -143,10 +144,10 @@ TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) game = makeGame(false, false, true, true); // A plain user who is not on the creator's buddy list gets rejected. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {}, true), Response::RespOnlyBuddies); // Moderators and judges bypass the buddies-only restriction. - EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, QString(), {}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, QString(), {}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) @@ -154,11 +155,11 @@ TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) game = makeGame(false, false, false, false); // A plain user cannot spectate when the game disallows spectators. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isSpectator = true}, false), Response::RespSpectatorsNotAllowed); // Moderators and judges may spectate any game regardless of the password // and the spectator restriction. - EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, "wrong", {.isSpectator = true}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isSpectator = true}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) @@ -166,8 +167,8 @@ TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) game = makeGame(false, false, false, true); // joining with join_as_judge still requires the judge flag even when overriding. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow); - EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isJudge = true}, true), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&judge, QString(), {.isJudge = true}, true), Response::RespOk); } } // namespace From 8b0c980e85515c5e7d8a5e04a3889943b29ac756 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:39:07 +0100 Subject: [PATCH 187/247] [Client] Guard dragged card ghost from painting or dropping deleted cards (#7688) * [Client] Guard dragged card ghost from painting or dropping deleted cards * [Game] Revert handling for cards destroyed mid-drag Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> * [DeckView] Access pointer through QPointer in DeckViewCardDragItem --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: zeldazach <7460172+zeldazach@users.noreply.github.com> --- .../board/abstract_card_drag_item.cpp | 20 ++++++++++++++++++- .../board/abstract_card_drag_item.h | 10 +++++----- .../game_graphics/board/card_drag_item.cpp | 7 ++++--- .../src/game_graphics/deckview/deck_view.cpp | 2 +- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp index 2e18fca2d..c707b4022 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item, update(); }); - connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater); + // The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough: + // a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately. + connect(_item, &QObject::destroyed, this, [this]() { + hide(); + if (QGraphicsScene *sc = scene()) { + sc->removeItem(this); + } + deleteLater(); + }); +} + +AbstractCardItem *AbstractCardDragItem::getItem() const +{ + return item.data(); } QPainterPath AbstractCardDragItem::shape() const @@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { + if (item.isNull()) { + return; + } + item->paint(painter, option, widget); // adds a mask to the card so it looks like the card hasnt been placed yet diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h index 18afdcbb8..80e40ff1d 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem Q_OBJECT Q_INTERFACES(QGraphicsItem) protected: - AbstractCardItem *item; + /// Guarded pointer: the source card may be destroyed while this drag item still exists. + QPointer item; QPointF hotSpot; QList childDrags; @@ -44,10 +46,8 @@ public: } [[nodiscard]] QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; - [[nodiscard]] AbstractCardItem *getItem() const - { - return item; - } + /// @return the source card, or nullptr if it has already been destroyed. + [[nodiscard]] AbstractCardItem *getItem() const; [[nodiscard]] QPointF getHotSpot() const { return hotSpot; diff --git a/cockatrice/src/game_graphics/board/card_drag_item.cpp b/cockatrice/src/game_graphics/board/card_drag_item.cpp index 265ea759c..b263e8c92 100644 --- a/cockatrice/src/game_graphics/board/card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/card_drag_item.cpp @@ -130,8 +130,9 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) sc->removeItem(this); QList dragItemList; - CardZoneLogic *startZone = static_cast(item)->getZone(); - if (currentZone && !(static_cast(item)->getAttachedTo() && (startZone == currentZone->getLogic()))) { + CardZoneLogic *startZone = static_cast(item.data())->getZone(); + if (currentZone && + !(static_cast(item.data())->getAttachedTo() && (startZone == currentZone->getLogic()))) { if (!occupied) { dragItemList.append(this); } @@ -139,7 +140,7 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) for (int i = 0; i < childDrags.size(); i++) { CardDragItem *c = static_cast(childDrags[i]); if (!occupied && - !(static_cast(c->item)->getAttachedTo() && (startZone == currentZone->getLogic())) && + !(static_cast(c->item.data())->getAttachedTo() && (startZone == currentZone->getLogic())) && !c->occupied) { dragItemList.append(c); } diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 630530792..6e0b0eaaa 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -77,7 +77,7 @@ void DeckViewCardDragItem::updatePosition(const QPointF &cursorScenePos) void DeckViewCardDragItem::handleDrop(DeckViewCardContainer *target) { - auto *card = static_cast(item); + auto *card = static_cast(item.data()); auto *start = static_cast(item->parentItem()); start->removeCard(card); target->addCard(card); From 9f30892dffb68a5551e20ecee65db5429be7f354 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:28:23 +0100 Subject: [PATCH 188/247] [Client] Fix crash when local game commands are sent after shutdown (#7714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Fix crash when local game commands are sent after shutdown * [Network] Fix formatting in local client debug log * [CI] Keep the vcpkg submodule pointer on the manifest baseline vcpkg.json asks for builtin-baseline 9eec002 (vcpkg 2026.10.16), but the submodule gitlink was still at 9e593bb. The runners check submodules out shallowly, so vcpkg-tool cannot read versions/baseline.json out of the baseline commit and every vcpkg leg (Windows and all macOS) fails in CMake configure with: failed to `git show` versions/baseline.json git phrases that as "path ... exists on disk, but not in " even when the commit is simply absent from the shallow clone, so it reads like a tooling or network failure rather than a mismatched pointer. Point the submodule back at 9eec002, and add a cheap job that fails when the gitlink and builtin-baseline disagree, before the five build legs pay for it. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 32 +++- cockatrice/src/interface/window_main.cpp | 6 +- .../network/client/local/local_client.cpp | 10 +- .../network/client/local/local_client.h | 6 +- tests/CMakeLists.txt | 12 ++ tests/local_client_lifetime_test.cpp | 149 ++++++++++++++++++ vcpkg | 2 +- 7 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 tests/local_client_lifetime_test.cpp diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 54068f5a0..df054ea26 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -101,6 +101,36 @@ jobs: --title "$release_name" \ --notes-file "$body_path" + vcpkg-baseline: + name: vcpkg baseline + runs-on: ubuntu-slim + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + # vcpkg-tool reads versions/baseline.json out of the commit named by + # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule + # pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by + # `git add` in a PR - makes every vcpkg leg fail in configure with "failed to + # `git show` versions/baseline.json". That reads like a baseline or network + # problem, when the pointer is what has to move. Check the two agree here in + # seconds instead of on five build legs. + - name: "Check the submodule pointer against the manifest baseline" + shell: bash + run: | + pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)" + baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)" + if [[ -z "$pointer" || -z "$baseline" ]]; then + echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline" + exit 1 + fi + if [[ "$pointer" != "$baseline" ]]; then + echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline" + echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg" + exit 1 + fi + echo "vcpkg submodule and builtin-baseline agree on $baseline" + build-linux: strategy: fail-fast: false @@ -466,7 +496,7 @@ jobs: vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} - needs: configure + needs: [configure, vcpkg-baseline] runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index f8ab5d3f6..d546676bc 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -254,7 +254,11 @@ void MainWindow::actWatchReplay() void MainWindow::localGameEnded() { - delete localServer; + // Defer deletion so the LocalClients (already scheduled with deleteLater() in TabSupervisor::stop) + // and any queued commands are processed before the server and its interfaces are destroyed. + if (localServer) { + localServer->deleteLater(); + } localServer = nullptr; aConnect->setEnabled(true); diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp index f7d8645e8..b9a1566f8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp @@ -33,16 +33,18 @@ LocalClient::LocalClient(LocalServerInterface *_lsi, sendCommand(prepareSessionCommand(joinCmd)); } -LocalClient::~LocalClient() -{ -} - void LocalClient::sendCommandContainer(const CommandContainer &cont) { if (LocalClientLog().isDebugEnabled()) { qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); } + if (lsi.isNull()) { + qCWarning(LocalClientLog).noquote() + << userName << "dropping command, local server interface no longer exists:" << getSafeDebugString(cont); + return; + } + lsi->itemFromClient(cont); } diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.h b/libcockatrice_network/libcockatrice/network/client/local/local_client.h index 3ae570255..15b36d3db 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.h +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.h @@ -10,6 +10,7 @@ #include "../abstract/abstract_client.h" #include +#include #include #include @@ -24,14 +25,15 @@ class LocalClient : public AbstractClient { Q_OBJECT private: - LocalServerInterface *lsi; + // Guarded pointer: the LocalServer (and its interfaces) may be destroyed while this client + // still has pending commands queued on the event loop. + QPointer lsi; public: LocalClient(LocalServerInterface *_lsi, const QString &_playerName, const QString &_clientId, QObject *parent = nullptr); - ~LocalClient() override; void sendCommandContainer(const CommandContainer &cont) override; private slots: diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 261900c01..0bb9654a1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -350,6 +350,18 @@ cockatrice_add_test( LABELS ci ) +# The local game runs one client and one server in the same process. +# Either of them can be destroyed first; the survivor has to stay usable. +cockatrice_add_test( + NAME + local_client_lifetime_test + SOURCES + local_client_lifetime_test.cpp + LIBS + libcockatrice_network + LABELS + ci +) cockatrice_add_test( NAME server_room_channels_test diff --git a/tests/local_client_lifetime_test.cpp b/tests/local_client_lifetime_test.cpp new file mode 100644 index 000000000..95a2b1380 --- /dev/null +++ b/tests/local_client_lifetime_test.cpp @@ -0,0 +1,149 @@ +/** + * @file local_client_lifetime_test.cpp + * @brief Tests that a LocalClient survives the death of the LocalServer it talks to. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The server library refers to this global; the real applications define it in main.cpp. +RNG_Abstract *rng = nullptr; + +namespace +{ + +// GTest's main() creates no QCoreApplication, which QObject::deleteLater() needs. +const QCoreApplication &application() +{ + static int argc = 1; + static char executable[] = "local_client_lifetime_test"; + static char *argv[] = {executable, nullptr}; + static const QCoreApplication app(argc, argv); + return app; +} + +/// Collects the warnings one logging category emits while this object is alive. +/// +/// The handler is process-global and Qt does not nest handlers, so only one capture can be +/// installed at a time; GTest running one case at a time is what makes that safe. +class CategoryWarningCapture +{ +public: + explicit CategoryWarningCapture(const char *_category) : category(_category) + { + previousHandler = qInstallMessageHandler(captureMessage); + s_active = this; + } + + ~CategoryWarningCapture() + { + qInstallMessageHandler(previousHandler); + s_active = nullptr; + } + + CategoryWarningCapture(const CategoryWarningCapture &) = delete; + CategoryWarningCapture &operator=(const CategoryWarningCapture &) = delete; + + const QStringList &warnings() const + { + return capturedWarnings; + } + +private: + static void captureMessage(QtMsgType type, const QMessageLogContext &context, const QString &message) + { + if (s_active != nullptr && type == QtWarningMsg && context.category != nullptr && + std::strcmp(s_active->category, context.category) == 0) { + s_active->capturedWarnings.append(message); + } + + // Forward everything else, so a failure elsewhere in the test is still visible. + if (previousHandler != nullptr) { + previousHandler(type, context, message); + return; + } + std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, message))); + } + + const char *category; + QStringList capturedWarnings; + + static CategoryWarningCapture *s_active; + static QtMessageHandler previousHandler; +}; + +CategoryWarningCapture *CategoryWarningCapture::s_active = nullptr; +QtMessageHandler CategoryWarningCapture::previousHandler = nullptr; + +class LocalClientLifetimeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + (void)application(); + + server = std::make_unique(); + LocalServerInterface *lsi = server->newConnection(); + ASSERT_NE(lsi, nullptr); + // Building the client logs in and joins room 0 through that interface, exactly as + // MainWindow::startLocalGame() does. + client = std::make_unique(lsi, QStringLiteral("lifetime-test-player"), + QStringLiteral("lifetime-test-client-id")); + } + + void TearDown() override + { + // Either case may already have deleted one of these. + client.reset(); + server.reset(); + } + + std::unique_ptr server; + std::unique_ptr client; +}; + +TEST_F(LocalClientLifetimeTest, SendAfterServerDeletedIsDropped) +{ + server.reset(); // also destroys the LocalServerInterface the client was built on + + CategoryWarningCapture capture("local_client"); + // A default container stands in for a command still queued when the local game ended. + client->sendCommandContainer(CommandContainer()); + + ASSERT_EQ(capture.warnings().size(), 1); + EXPECT_TRUE(capture.warnings().first().contains(QStringLiteral("dropping command"))) + << capture.warnings().first().toStdString(); + + // The surviving client is intact: reading its state must not touch the deleted interface. + EXPECT_EQ(client->getUserName(), QStringLiteral("lifetime-test-player")); +} + +TEST_F(LocalClientLifetimeTest, DestroyingClientAfterServerIsSafe) +{ + server.reset(); + + CategoryWarningCapture capture("local_client"); + client.reset(); // must neither crash nor touch the destroyed interface + + // The destructor itself must have been silent. + EXPECT_TRUE(capture.warnings().isEmpty()); + + // A working capture proves the emptiness above is real rather than a broken handler. + qCWarning(LocalClientLog) << "local_client_lifetime_test capture self-check"; + EXPECT_EQ(capture.warnings().size(), 1); +} + +} // namespace diff --git a/vcpkg b/vcpkg index 9e593bb18..9eec002df 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d +Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 From 44d513818110343a61bbbd9e478502f0b9bcb69c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:18:19 +0100 Subject: [PATCH 189/247] [VisualDatabase] Keep the card grid scrolling on macOS (#7764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VisualDatabase] Keep the card grid scrolling on macOS The view ignored the wheel whenever the vertical scroll bar was not visible. On macOS, scroll bars are transient: once the fade-out finishes, QScrollbarStyleAnimation hides the scroll bar widget, so isVisible() then reports false even though the grid has more rows to scroll. Scrolling was dead until something else brought the bar back. Gate on the scroll bar's range instead: it says whether there is anything to scroll, while visibility is only about presentation. An event with no range to scroll is still ignored, so an enclosing scroll area can take it. * Update card_database_visual_view.cpp --------- Co-authored-by: Lukas Brübach --- .../visual_database_display/card_database_visual_view.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp index 7b0d526f9..a8262c75a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp @@ -130,8 +130,11 @@ void CardDatabaseVisualView::wheelEvent(QWheelEvent *event) return; } + // macOS uses transient (overlay) scroll bars that hide themselves + // once they fade out, so we need to use the range to determine if we ignore the wheel when there is nothing to + // scroll. This lets an enclosing scroll area take over. QScrollBar *bar = verticalScrollBar(); - if (!bar->isVisible()) { + if (bar->maximum() <= bar->minimum()) { event->ignore(); return; } From dd7ee3541c873beac3bf29df67835919c5657e0f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:17:53 +0100 Subject: [PATCH 190/247] [PictureLoader] Keep the download log from going quadratic (#7766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [PictureLoader] Keep the download log from going quadratic A fast scrollbar sweep over the visual card database requests a picture for every cell it paints, so one gesture can ask for thousands of downloads. The status bar built a seven-widget log entry for each of them and answered every completion with findChildren() over the whole, unbounded log, rebuilding url.toString() per child: O(n) per completion, O(n^2) per burst. Profiling a sweep on master put 47% of all cycles on the GUI thread, with qt_qFindChildren_helper at 12.4% of the process (a quarter of that thread) and QWidgetPrivate::reparentFocusChildren at 4.1%. LagMonitor reported stalls of 3-18 s, growing with the backlog. Keep at most MAX_LOG_ENTRIES entries and index them by URL, so a completion and the 1 Hz cleanup only visit the matching entries. The progress bar follows the entries that remain: dropping a finished entry moves both counters back, dropping a pending one only its maximum. The log is a diagnostic view, not a queue - the downloads themselves are unaffected. * fixup! [PictureLoader] Keep the download log from going quadratic Make the URL index a QMultiHash and say why: a deferred retry re-queues the same URL while its first log entry is still pending, so one URL can back several live entries. --------- Co-authored-by: Lukas Brübach --- .../card_picture_loader_status_bar.cpp | 93 ++++++++++++++++--- .../card_picture_loader_status_bar.h | 30 ++++++ 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp index 3b26c8033..a731d067a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp @@ -11,6 +11,7 @@ #include #include #include +#include class ExactCard; @@ -38,32 +39,94 @@ void CardPictureLoaderStatusBar::cleanOldEntries() if (!loadLog || !loadLog->popup) { return; } - for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : - loadLog->popup->findChildren()) { + + const QDateTime now = QDateTime::currentDateTime(); + // Removing an entry mutates the log, so walk a copy of it. + const QList entries = logEntries; + for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) { statusDisplayWidget->queryElapsedSeconds(); - if (statusDisplayWidget->getFinished() && - QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(QDateTime::currentDateTime()) > 10) { - loadLog->removeSettingsWidget(statusDisplayWidget); - progressBar->setMaximum(progressBar->maximum() - 1); - progressBar->setValue(progressBar->value() - 1); + if (!statusDisplayWidget->getFinished()) { + continue; + } + if (QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(now) > LOG_ENTRY_LIFETIME_SECS) { + removeLogEntry(statusDisplayWidget); } } } void CardPictureLoaderStatusBar::addQueuedImageLoad(const QUrl &url, const ExactCard &card, const QString &setName) { - loadLog->addSettingsWidget(new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName)); + if (logEntries.size() >= MAX_LOG_ENTRIES) { + trimLog(); + } + + auto *entry = new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName); + loadLog->addSettingsWidget(entry); + logEntries.append(entry); + entriesByUrl.insert(entry->getUrl(), entry); progressBar->setMaximum(progressBar->maximum() + 1); } void CardPictureLoaderStatusBar::addSuccessfulImageLoad(const QUrl &url) { - progressBar->setValue(progressBar->value() + 1); - for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : - loadLog->popup->findChildren()) { - if (statusDisplayWidget->getUrl() == url.toString()) { - statusDisplayWidget->queryElapsedSeconds(); - statusDisplayWidget->setFinished(); + const QList entries = entriesByUrl.values(url.toString()); + if (entries.isEmpty()) { + // The entry may already have been dropped from the log; the download + // itself still succeeded, there is just nothing left to mark. + return; + } + + int finishedEntries = 0; + for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) { + if (statusDisplayWidget->getFinished()) { + continue; + } + statusDisplayWidget->queryElapsedSeconds(); + statusDisplayWidget->setFinished(); + ++finishedEntries; + } + + // The bar counts the entries currently in the log, so only entries that are + // still listed move it. + progressBar->setValue(progressBar->value() + finishedEntries); +} + +void CardPictureLoaderStatusBar::trimLog() +{ + if (logEntries.isEmpty()) { + return; + } + + // Prefer evicting an entry that has already finished; only fall back to an + // in-flight request when the whole log is still pending. + CardPictureLoaderRequestStatusDisplayWidget *toRemove = nullptr; + for (CardPictureLoaderRequestStatusDisplayWidget *entry : logEntries) { + if (entry->getFinished()) { + toRemove = entry; + break; } } -} \ No newline at end of file + + removeLogEntry(toRemove != nullptr ? toRemove : logEntries.constFirst()); +} + +void CardPictureLoaderStatusBar::removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry) +{ + if (entry == nullptr) { + return; + } + + logEntries.removeOne(entry); + entriesByUrl.remove(entry->getUrl(), entry); + + // The progress bar's maximum counts the entries currently in the log and + // its value the finished ones, so dropping an entry moves both counters back. + // Move the value first: a maximum of 0 turns the bar into a busy indicator and + // resets its value. + if (entry->getFinished()) { + progressBar->setValue(qMax(0, progressBar->value() - 1)); + } + progressBar->setMaximum(qMax(0, progressBar->maximum() - 1)); + + loadLog->removeSettingsWidget(entry); +} diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h index 2ba386c42..7f614e28f 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h @@ -1,10 +1,13 @@ #ifndef PICTURE_LOADER_STATUS_BAR_H #define PICTURE_LOADER_STATUS_BAR_H +#include +#include #include #include #include +class CardPictureLoaderRequestStatusDisplayWidget; class ExactCard; class QHBoxLayout; class QProgressBar; @@ -21,11 +24,20 @@ class SettingsButtonWidget; * - Shows overall progress of image downloads. * - Maintains a log of individual requests via a popup (SettingsButtonWidget). * - Cleans up finished request entries automatically after a delay. + * + * The log is a diagnostic view, not a queue: it keeps at most MAX_LOG_ENTRIES + * entries and indexes them by URL, so a burst of requests stays O(1) per event + * instead of scanning every entry the popup holds. */ class CardPictureLoaderStatusBar : public QWidget { Q_OBJECT public: + /** Entries kept in the log; older ones are evicted as new requests arrive. */ + static constexpr int MAX_LOG_ENTRIES = 50; + /** Seconds a finished entry stays in the log before it is cleaned up. */ + static constexpr int LOG_ENTRY_LIFETIME_SECS = 10; + /** * @brief Constructs a status bar with progress bar and log button. * @param parent Parent widget @@ -55,10 +67,28 @@ public slots: void cleanOldEntries(); private: + /** + * @brief Evicts the oldest finished entry (or the oldest entry if none finished) + * until the log fits within MAX_LOG_ENTRIES. + */ + void trimLog(); + + /** + * @brief Removes one entry from the log, its URL index and the popup, and keeps + * the progress bar counters in step with the entries that remain. + * @param entry Entry to remove + */ + void removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry); + QHBoxLayout *layout; ///< Horizontal layout containing progress bar and log button QProgressBar *progressBar; ///< Progress bar showing overall download progress SettingsButtonWidget *loadLog; ///< Popup log showing individual request statuses QTimer *cleaner; ///< Timer for periodically cleaning old log entries + QList logEntries; ///< Logged requests, oldest first + // A URL can back several live entries: a request parked for a deferred retry is re-queued while + // its first entry is still pending, so a completion must finish every entry for that URL. + QMultiHash + entriesByUrl; ///< Log entries indexed by their URL for O(1) completion lookup }; #endif // PICTURE_LOADER_STATUS_BAR_H From c5b776ca46792b4d9fbcc1c17362958e4e604705 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:31:12 +0100 Subject: [PATCH 191/247] Translate oracle/oracle_en@source.ts in de (#7767) 100% translated source file: 'oracle/oracle_en@source.ts' on 'de'. Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- oracle/translations/oracle_de.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/oracle/translations/oracle_de.ts b/oracle/translations/oracle_de.ts index faf08d5ce..50a5f8e20 100644 --- a/oracle/translations/oracle_de.ts +++ b/oracle/translations/oracle_de.ts @@ -137,22 +137,22 @@ Downloading (0 MB) - + Herunterladen (0 MB) Downloading (%1 MB) - + Herunterladen (%1 MB) Parsing file (%1%) - + Datei wird verarbeitet (%1%) Extracting file... - + Extrahiere Datei... @@ -168,7 +168,7 @@ Parsing file (100%) - + Datei wird verarbeitet (100%) @@ -348,7 +348,7 @@ A Cockatrice card database file of %1 MB has been downloaded. - + Eine Cockatrice-Kartendatenbankdatei der Größe %1 MB wurde heruntergeladen. @@ -434,7 +434,7 @@ Downloading (0 MB) Downloading (0MB) - + Herunterladen (0 MB) From 381dd21e8c5441794a2df3f272037ff96641ffb8 Mon Sep 17 00:00:00 2001 From: PILIX123 <89544445+PILIX123@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:33:21 -0500 Subject: [PATCH 192/247] [Client] Add option to load decks from ManaBox (#7642) * [Client] Add option to load decks from ManaBox * [Client] Fix PR comments * [Client] Fix UI and transaltion requests * [Client] Fix for UI and parser not doing what expected when loading maybe board * Add ManaBox support to docs As pointed out in review comments * [Client] Fix final PR comments --------- Co-authored-by: tooomm --- .../parsers/deck_link_to_api_transformer.cpp | 15 ++- .../parsers/deck_link_to_api_transformer.h | 1 + .../parsers/interface_json_deck_parser.h | 106 +++++++++++++++++- .../dialogs/dlg_load_deck_from_website.cpp | 16 ++- .../deck_management/importing_decks.md | 2 +- 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp index 7127b0a29..2b307735d 100644 --- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp +++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp @@ -15,14 +15,18 @@ static const QString ARCHIDEKT_SUFFIX = "/?format=json"; static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/"; static const QString MOXFIELD_SUFFIX = "/"; +static const QString DECKSTATS_BASE = "https://deckstats.net/decks/"; static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1"; +static const QString MANABOX_BASE = "https://cloud.manabox.app/decks/"; + bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) { static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)"); static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)"); static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)"); static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)"); + static QRegularExpression rxManabox("manabox\\.app/decks/([a-zA-Z0-9_-]+)"); QRegularExpressionMatch match; @@ -49,11 +53,18 @@ bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) return true; } else if ((match = rxDeckstats.match(url)).hasMatch()) { QString deckPath = match.captured(1); - outInfo = ParsedDeckInfo{.baseUrl = "https://deckstats.net/decks/", + outInfo = ParsedDeckInfo{.baseUrl = DECKSTATS_BASE, .deckID = deckPath, - .fullUrl = "https://deckstats.net/decks/" + deckPath + DECKSTATS_SUFFIX, + .fullUrl = DECKSTATS_BASE + deckPath + DECKSTATS_SUFFIX, .provider = DeckProvider::Deckstats}; return true; + } else if ((match = rxManabox.match(url)).hasMatch()) { + QString deckPath = match.captured(1); + outInfo = ParsedDeckInfo{.baseUrl = MANABOX_BASE, + .deckID = deckPath, + .fullUrl = MANABOX_BASE + deckPath, + .provider = DeckProvider::Manabox}; + return true; } return false; diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h index bbdd70c97..d3bff976f 100644 --- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h +++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h @@ -15,6 +15,7 @@ enum class DeckProvider Archidekt, Moxfield, Deckstats, + Manabox, Unknown }; diff --git a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h index 87fde4d54..8f1defb7a 100644 --- a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h +++ b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h @@ -8,12 +8,19 @@ #define INTERFACE_JSON_DECK_PARSER_H #include "../../../interface/deck_loader/card_node_function.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" #include #include +#include +#include +#include +#include #include #include +inline Q_LOGGING_CATEGORY(InterfaceJsonDeckParserLog, "interface_json_deck_parser"); + class IJsonDeckParser { public: @@ -32,9 +39,6 @@ public: QString deckName = obj.value("name").toString(); QString deckDescription = obj.value("description").toString(); - deckList.setName(deckName); - deckList.setComments(deckDescription); - QString outputText; QTextStream outStream(&outputText); @@ -52,6 +56,8 @@ public: deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + deckList.setName(deckName); + deckList.setComments(deckDescription); return deckList; } @@ -67,9 +73,6 @@ public: QString deckName = obj.value("name").toString(); QString deckDescription = obj.value("description").toString(); - deckList.setName(deckName); - deckList.setComments(deckDescription); - QString outputText; QTextStream outStream(&outputText); @@ -99,6 +102,8 @@ public: deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + deckList.setName(deckName); + deckList.setComments(deckDescription); QJsonObject commandersObj = obj.value("commanders").toObject(); if (!commandersObj.isEmpty()) { @@ -118,4 +123,93 @@ public: } }; +class ManaboxJsonParser : public IJsonDeckParser +{ + enum BoardType : quint32 + { + MainBoard = 0, + SideBoard = 1, + Commander = 2, + MaybeBoard = 3, + }; + +public: + DeckList parse(const QJsonObject &obj) override + { + DeckList deckList; + + QString deckName = obj.value("name").toString(); + + QString outputText; + QString mainboardText; + QString sideboardText; + QTextStream mainboardStream(&mainboardText); + QTextStream sideboardStream(&sideboardText); + QTextStream outStream(&outputText); + + auto commanderList = QList(); + auto maybeboardList = QList(); + for (auto entry : obj.value("cards").toArray()) { + auto card = entry.toObject(); + + auto collectorNumber = card.value("collectorNumber").toString(); + auto cardName = card.value("name").toString(); + auto boardType = static_cast(card.value("boardCategory").toInt()); + auto setName = card.value("setId").toString(); + auto quantity = card.value("quantity").toInt(); + auto providerId = card.value("scryfallId").toString(); + + switch (boardType) { + case MainBoard: + mainboardStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber + << '\n'; + break; + case SideBoard: + sideboardStream << "SB: " << quantity << ' ' << cardName << " (" << setName << ") " + << collectorNumber << '\n'; + break; + case MaybeBoard: + maybeboardList.append(card); + break; + case Commander: + commanderList.append(card); + break; + default: + qCWarning(InterfaceJsonDeckParserLog) + << "Unknown ManaBox Zone" << card.value("boardCategory").toInt() << "for" << cardName; + break; + } + } + + outStream << mainboardText << sideboardText; + + deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + + for (const auto &maybeboardCard : maybeboardList) { + QString cardName = maybeboardCard.value("name").toString(); + QString setName = maybeboardCard.value("setId").toString().toUpper(); + QString collectorNumber = maybeboardCard.value("collectorNumber").toString(); + QString providerId = maybeboardCard.value("scryfallId").toString(); + + for (int x = 0; x < maybeboardCard.value("quantity").toInt(); x++) { + deckList.addCard(cardName, DECK_ZONE_MAYBEBOARD, -1, setName, collectorNumber, providerId); + } + } + for (const auto &commanderCard : commanderList) { + + QString commanderName = commanderCard.value("name").toString(); + QString setName = commanderCard.value("setId").toString().toUpper(); + QString collectorNumber = commanderCard.value("collectorNumber").toString(); + QString providerId = commanderCard.value("scryfallId").toString(); + + deckList.setBannerCard({commanderName, providerId}); + deckList.addCard(commanderName, DECK_ZONE_MAIN, -1, setName, collectorNumber, providerId); + } + + return deckList; + } +}; + #endif // INTERFACE_JSON_DECK_PARSER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp index 1c1ada302..04b0db2e3 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ class QWidget; +static const QStringList SUPPORTED_PROVIDERS = {"Archidekt", "Deckstats", "ManaBox", "Moxfield", "TappedOut"}; DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) { nam = NetworkManagerFactory::create(this); @@ -41,6 +43,7 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent setLayout(layout); instructionLabel = new QLabel(this); + instructionLabel->setWordWrap(true); layout->addWidget(instructionLabel); urlEdit = new QLineEdit(this); @@ -64,8 +67,8 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent void DlgLoadDeckFromWebsite::retranslateUi() { - instructionLabel->setText(tr("Paste a link to a decklist site here to import it.\n(Archidekt, Deckstats, Moxfield, " - "and TappedOut are supported.)")); + instructionLabel->setText(tr("Paste a link to a decklist site here to import it.") + "\n" + + tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", "))); } bool DlgLoadDeckFromWebsite::testValidUrl() @@ -84,8 +87,8 @@ void DlgLoadDeckFromWebsite::accept() if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) { qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider"; QMessageBox::warning(this, tr("Load Deck from Website"), - tr("No parser available for this deck provider.\n (Archidekt, Deckstats, Moxfield, " - "and TappedOut are supported.)")); + tr("No parser available for this deck provider.") + "\n" + + tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", "))); QDialog::reject(); return; } @@ -152,6 +155,7 @@ void DlgLoadDeckFromWebsite::accept() "https://archidekt.com/decks/9999999\n" "https://deckstats.net/decks/99999/9999999-your-deck-name/en\n" "https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n" + "https://manabox.app/decks/XYZxxa-YZ99Yyyx_yzXzzz\n" "https://tappedout.net/mtg-decks/your-deck-name/")); QDialog::reject(); } @@ -164,7 +168,9 @@ QSharedPointer DlgLoadDeckFromWebsite::createParserForProvider( return QSharedPointer(new ArchidektJsonParser()); case DeckProvider::Moxfield: return QSharedPointer(new MoxfieldJsonParser()); + case DeckProvider::Manabox: + return QSharedPointer(new ManaboxJsonParser()); default: return QSharedPointer(nullptr); } -} \ No newline at end of file +} diff --git a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md index 4aab7ad2c..fbfb8fe3c 100644 --- a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md +++ b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md @@ -70,4 +70,4 @@ confirm. The action will automatically import the deck from the online service without any other required user action. -Currently supported services are Archidekt, Deckstats, Moxfield, and TappedOut. \ No newline at end of file +Currently supported services are Archidekt, Deckstats, ManaBox, Moxfield, and TappedOut. From bfb53c5defeff356081789ff60bb103fd8fca75e Mon Sep 17 00:00:00 2001 From: Jurriaan Date: Tue, 3 Nov 2026 10:35:58 +0100 Subject: [PATCH 193/247] [Client] Visual Deck Editor: Added more mouse click modifiers to add/decrement cards to specific zones (#7589) * Make visual database mouse click modifiers properly add/decrement different card zones * Use event->modifiers(), refactor * Removed QApplication include * Added visual database display documentation page * Minor fixes * Added documentation for the mouse interaction of the table view --- .../tab_deck_editor_visual.cpp | 21 ++++++++++------ .../deck_management/editing_decks.md | 7 +++--- .../visual_database_display.md | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md 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 bf4e15aa6..84bf026f7 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 @@ -20,7 +20,6 @@ #include "tab_deck_editor_visual_tab_widget.h" #include -#include #include #include #include @@ -261,17 +260,25 @@ void TabDeckEditorVisual::processMainboardCardClick(const QMouseEvent *event, } } +/** @brief Get a zone name corresponding to keyboard modifiers. */ +static QString zoneForClickModifiers(const Qt::KeyboardModifiers &modifiers) +{ + if (modifiers.testFlag(Qt::ControlModifier)) { + return DECK_ZONE_SIDE; + } else if (modifiers.testFlag(Qt::ShiftModifier)) { + return DECK_ZONE_MAYBEBOARD; + } else { + return DECK_ZONE_MAIN; + } +} + /** @brief Handles clicks on cards in the database display. */ void TabDeckEditorVisual::processDatabaseCardClick(const QMouseEvent *event, const ExactCard &card) { if (event->button() == Qt::LeftButton) { - if (QApplication::keyboardModifiers() & Qt::ControlModifier) { - addCard(card, DECK_ZONE_SIDE); - } else { - addCard(card, DECK_ZONE_MAIN); - } + addCard(card, zoneForClickModifiers(event->modifiers())); } else if (event->button() == Qt::RightButton) { - decrementCard(card, DECK_ZONE_MAIN); + decrementCard(card, zoneForClickModifiers(event->modifiers())); } else if (event->button() == Qt::MiddleButton) { deckDockWidget->actRemoveCard(); } diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md index 37b613461..04833282d 100644 --- a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md +++ b/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md @@ -1,6 +1,7 @@ @page editing_decks Editing Decks - - @subpage editing_decks_classic - - @subpage editing_decks_visual +- @subpage editing_decks_classic +- @subpage editing_decks_visual - - @subpage editing_decks_printings +- @subpage visual_database_display +- @subpage editing_decks_printings diff --git a/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md b/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md new file mode 100644 index 000000000..097848ab8 --- /dev/null +++ b/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md @@ -0,0 +1,25 @@ +@page visual_database_display Visual Database Display + +# Purpose + +The VisualDatabaseDisplay presents all the cards from the enabled sets in the CardDatabase, providing a visual way to find and add cards to your DeckList. + +# Using the Visual Database Display + +The VisualDatabaseDisplay can be opened by itself using the Tabs section in the menu bar at the top of the screen, or by selecting the "Visual Database Display" tab in the visual deck editor. When opened as a part of the visual deck editor, the VisualDatabaseDisplay can be used to add and remove cards from your deck. See @ref editing_decks_visual for more information about modifying your deck. + +By default the cards are displayed as a grid of pictures. However, a table view using card names can be toggled by pressing the Visual/Table button at the top right of the widget. + +# Filtering cards + +The top section of the VisualDatabaseDisplay can be used to filter cards based on various card properties, as well as specifying in which way they should be sorted. Filters can be saved and reused at a later time by using the Save/Load button at the right of this panel. + +# Modifying the Deck list + +When the VisualDatabaseDisplay is opened as part of the visual deck editor, clicking on cards adds or removes them to and from your deck. + +In the picture view, left-clicking on a card will add that card to the main board of your deck, while right-clicking will remove that printing of the card from the main board, if it exists. When clicking cards, modifier keys can be used to add (or remove) the card to zones other than the main board. Holding a Ctrl key on Windows and Linux or a Command key on MacOS adds/removes cards to the sideboard, while the Shift keys add/remove cards to the maybeboard. + +In the table view, double-clicking on a card adds that card to the main board of your deck, while right-click opens the card menu. Holding the Ctrl key on Windows and Linux or the Command key on MacOS when double-clicking adds the card to the sideboard instead. + +Tokens are always added to the token zone, regardless of which view is open or which modifier key is pressed. From bc09ec6afe31cd0fcf7e19a4afaf4ceadc6b1f2c Mon Sep 17 00:00:00 2001 From: Jurriaan Date: Tue, 3 Nov 2026 10:37:17 +0100 Subject: [PATCH 194/247] [Client] Make DeckStateManager::decrementCard remove tokens from the token zone if the token is not in the given zone (#7588) * Fixed decrementCard for tokens not using the token zone * Removed dead function actDecrementCard Updated documentation * Fixed no-op, added documentation * Removed stray blank line * Allow finding card nodes even if no database entry exists for the given card * Added DeckStateManager tests * Added tokens on mainboard test * Added if checks and sorted sources Added if checks * Library include fix * Renamed zone to targetZone * Revert "Allow finding card nodes even if no database entry exists for the given card" This reverts commit 88578cd0d954f50907a19ea539c824e7e83dfa3d. * Combined loader_local_matching_test and deck_state_manager_test settings sources * Added settings sandbox, refactor * Format * Renamed return variable * Flattened if statement * Style fix --- .../deck_editor_deck_dock_widget.cpp | 12 -- .../deck_editor_deck_dock_widget.h | 1 - .../deck_editor/deck_state_manager.cpp | 22 +- .../widgets/deck_editor/deck_state_manager.h | 5 +- tests/CMakeLists.txt | 60 ++++-- tests/deck_state_manager_test.cpp | 190 ++++++++++++++++++ 6 files changed, 253 insertions(+), 37 deletions(-) create mode 100644 tests/deck_state_manager_test.cpp diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 37dd57459..626a1586f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -762,18 +762,6 @@ void DeckEditorDeckDockWidget::actSwapSelection() update(); } -void DeckEditorDeckDockWidget::actDecrementCard(const ExactCard &card, QString zoneName) -{ - if (!card) { - return; - } - if (card.getInfo().getIsToken()) { - zoneName = DECK_ZONE_TOKENS; - } - - deckStateManager->decrementCard(card, zoneName); -} - void DeckEditorDeckDockWidget::actDecrementSelection() { auto selectedRows = getSelectedCardNodeSourceIndices(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 97ab8823e..fe03f02ae 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -70,7 +70,6 @@ public slots: void syncDisplayWidgetsToModel(); void actAddCard(const ExactCard &card, const QString &zoneName); void actIncrementSelection(); - void actDecrementCard(const ExactCard &card, QString zoneName); void actDecrementSelection(); void actSwapCard(const ExactCard &card, const QString &zoneName); void actSwapSelection(); 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 a7e231ecf..cbf9dd3c4 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -211,15 +211,15 @@ QModelIndex DeckStateManager::addCard(const ExactCard &card, const QString &zone return {}; } - QString zone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName; + QString targetZone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName; CardSetPtr set = card.getPrinting().getSet(); QString setName = set ? set->getCorrectedShortName() : ""; QString reason = - tr("Added (%1): %2 (%3) %4").arg(zone, card.getName(), setName, card.getPrinting().getProperty("num")); + tr("Added (%1): %2 (%3) %4").arg(targetZone, card.getName(), setName, card.getPrinting().getProperty("num")); - QModelIndex idx = modifyDeck(reason, [&card, &zone](auto model) { return model->addCard(card, zone); }); + QModelIndex idx = modifyDeck(reason, [&card, &targetZone](auto model) { return model->addCard(card, targetZone); }); if (idx.isValid()) { emit focusIndexChanged(idx, true); @@ -234,10 +234,22 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString return {}; } + QString targetZone = zoneName; + QString providerId = card.getPrinting().getUuid(); QString collectorNumber = card.getPrinting().getProperty("num"); - QModelIndex idx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); + QModelIndex idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); + + // Tokens are usually added to the token zone, but it is possible for imported/hand-edited + // decks to contain them in the main or sideboards. This is why we only try to remove the + // token from the token zone if it does not exist in the given zone, allowing us to still + // remove it from there. + if (!idx.isValid() && card.getInfo().getIsToken() && targetZone != DECK_ZONE_TOKENS) { + targetZone = DECK_ZONE_TOKENS; + idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); + } + if (!idx.isValid()) { return {}; } @@ -249,7 +261,7 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString } // old index is no longer safe since rows could have been removed - QModelIndex newIdx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); + QModelIndex newIdx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); if (newIdx.isValid()) { emit focusIndexChanged(newIdx, true); } 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 54c59eb79..0612147eb 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -188,17 +188,18 @@ public: ///@} /** - * @brief Adds the given card to the given zone. + * @brief Adds the given card to the given zone. Tokens always go to DECK_ZONE_TOKENS, ignoring the given zone. * Saves the operation to history if successful. * * @param card The card to add - * @param zoneName The zone to add the card to + * @param zoneName The zone to add the card to if the card is not a token * @return The index of the added card */ QModelIndex addCard(const ExactCard &card, const QString &zoneName); /** * @brief Removes 1 copy of the given card from the given zone. + * If the card is a token and is not in the given zone, then it is removed from DECK_ZONE_TOKENS instead. * Saves the operation to history if successful. * * @param card The card to remove diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0bb9654a1..cccff48c3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -412,23 +412,6 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) -cockatrice_add_test( - NAME - loader_local_matching_test - SOURCES - ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp - ${VERSION_STRING_CPP} - loader_local_matching_test.cpp - LIBS - libcockatrice_network - libcockatrice_settings - INCLUDE_DIRS - ${CMAKE_SOURCE_DIR}/cockatrice/src -) cockatrice_add_test( NAME deck_hash_performance_test @@ -548,3 +531,46 @@ endif() if(WITH_ORACLE OR WITH_CLIENT) add_subdirectory(picture_loader_benchmark) endif() + +# loader_local_matching_test and deck_state_manager_test links libcockatrice_settings, +# which only exists when a client/UI-capable target is being built. +if(WITH_ORACLE OR WITH_CLIENT) + set(SETTINGS_SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${VERSION_STRING_CPP} + ) + set(SETTINGS_LIBS libcockatrice_network libcockatrice_settings) + set(SETTINGS_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src) + + cockatrice_add_test( + NAME + loader_local_matching_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${SETTINGS_SOURCES} + loader_local_matching_test.cpp + LIBS + ${SETTINGS_LIBS} + INCLUDE_DIRS + ${SETTINGS_INCLUDE_DIRS} + ) + cockatrice_add_test( + NAME + deck_state_manager_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp + ${SETTINGS_SOURCES} + deck_state_manager_test.cpp + LIBS + libcockatrice_card + libcockatrice_deck_list + libcockatrice_models + libcockatrice_utility + ${SETTINGS_LIBS} + INCLUDE_DIRS + ${SETTINGS_INCLUDE_DIRS} + ) +endif() diff --git a/tests/deck_state_manager_test.cpp b/tests/deck_state_manager_test.cpp new file mode 100644 index 000000000..ddd17912c --- /dev/null +++ b/tests/deck_state_manager_test.cpp @@ -0,0 +1,190 @@ +/** @file deck_state_manager_test.cpp + * @brief Tests for DeckStateManager operations. + * @ingroup Tests + */ + +#include "libcockatrice/card/database/card_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Assert that the DeckStateManager's deck list contains only the given zone, + * and that that zone only contains the given number of cards, all with the given card name. + * + * @param manager the DeckStateManager + * @param restrictToZones In which zones to look for the zone and cards. Empty means use the entire deck + * @param zone The only zone that should exist in the deck + * @param cardName The name of the only card that should exist in the deck + * @param cardNumber The number of cards that should be in the deck + */ +static void containsExactly(const DeckStateManager &manager, + const QSet &restrictToZones, + QString zone, + QString cardName, + int cardNumber) +{ + QList zones = manager.getDeckList().getZoneNodes(restrictToZones); + ASSERT_EQ(zones.size(), 1); + ASSERT_EQ(zones[0]->getName(), zone); + ASSERT_EQ(zones[0]->size(), 1); + const DecklistCardNode *card = dynamic_cast((*zones[0])[0]); + ASSERT_NE(card, nullptr); + ASSERT_EQ(card->getName(), cardName); + ASSERT_EQ(card->getNumber(), cardNumber); +} + +/** @brief Return CardInfo of a token with the given name. */ +static CardInfoPtr makeToken(QString name) +{ + return CardInfo::newInstance(name, "", true, {}, {}, {}, {}, {}); +} + +// ===================================================================================================================== +// addCard +// ===================================================================================================================== + +TEST(DeckStateManager, AddCards) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); +} + +TEST(DeckStateManager, AddCardZones) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Grizzly Bears")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), DECK_ZONE_MAYBEBOARD); + + // Check if the cards got put into the correct zones. + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 3); + containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Grizzly Bears", 1); + containsExactly(manager, {DECK_ZONE_SIDE}, DECK_ZONE_SIDE, "Lightning Bolt", 1); + containsExactly(manager, {DECK_ZONE_MAYBEBOARD}, DECK_ZONE_MAYBEBOARD, "Swords to Plowshares", 1); +} + +TEST(DeckStateManager, AddTokens) +{ + DeckStateManager manager; + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAYBEBOARD); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + + // A token should always be put into the tokens zone. + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 4); +} + +// ===================================================================================================================== +// decrementCard +// ===================================================================================================================== + +TEST(DeckStateManager, DecrementCards) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); + + // Removing from the sideboard should do nothing, as there is nothing in the side board. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); + + // Remove the first card. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1); + + // Removing the second card should also remove the zone. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} + +TEST(DeckStateManager, DecrementTokens) +{ + DeckStateManager manager; + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 3); + + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 2); + + // Removing tokens from a given zone should remove them from the + // token zone if the given zone does not contain the token. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 1); + + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} + +TEST(DeckStateManager, DecrementTokenMainboard) +{ + DeckStateManager manager; + + // Add cards to the deck list directly so tokens are not + // automatically added to the token zone. + manager.getDeckListShared()->addCard("Beast", DECK_ZONE_MAIN); + manager.getDeckListShared()->addCard("Beast", DECK_ZONE_TOKENS); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 2); + containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Beast", 1); + containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1); + + // decrementCard uses the model to find the card, so we need to rebuild it. + manager.getModel()->rebuildTree(); + + // Tokens should be removed from the main zone first. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes({DECK_ZONE_MAIN}).size(), 0); + containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1); + + // If the main zone is empty they can be removed from the token zone. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} +} // namespace + +int main(int argc, char **argv) +{ + // Redirect SettingsCache reads/writes (app-data location) away from the real user profile. + QStandardPaths::setTestModeEnabled(true); + QCoreApplication app(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("CockatriceTest")); + QCoreApplication::setApplicationName( + QStringLiteral("deck-state-manager-test-%1").arg(QCoreApplication::applicationPid())); + + ::testing::InitGoogleTest(&argc, argv); + + // Add cards to the card database + auto *db = CardDatabaseManager::getInstance(); + db->addCard(CardInfo::newInstance("Lightning Bolt")); + db->addCard(CardInfo::newInstance("Grizzly Bears")); + db->addCard(CardInfo::newInstance("Swords to Plowshares")); + db->addCard(makeToken("Beast")); + + int result = RUN_ALL_TESTS(); + + // Clean up the settings directory in ~/.qttest if the tests ran. + if (!::testing::GTEST_FLAG(list_tests)) { + QDir(SettingsCache::instance().getDataPath()).removeRecursively(); + QDir(SettingsCache::instance().getCachePath()).removeRecursively(); + } + + return result; +} From ada8f399f1a18152c1264943fd284c93ea605fac Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 04:38:54 -0500 Subject: [PATCH 195/247] Log server identification and connection lifecycle (#7731) * Log remote server identification and connection lifecycle [skip ci] * Update libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com> --------- Co-authored-by: BruebachL <44814898+BruebachL@users.noreply.github.com> Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com> --- .../network/client/remote/remote_client.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index faacdc9f7..df1413dd2 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -137,6 +137,7 @@ RemoteClient::~RemoteClient() void RemoteClient::slotSocketError(QAbstractSocket::SocketError /*error*/) { QString errorString = socket->errorString(); + qCWarning(RemoteClientLog) << "Server connection error:" << errorString; doDisconnectFromServer(); emit socketError(errorString); } @@ -146,6 +147,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/) QString errorString = websocket->errorString(); if (getStatus() != ClientStatus::StatusDisconnected) { + qCWarning(RemoteClientLog) << "Server connection error:" << errorString; doDisconnectFromServer(); emit socketError(errorString); } @@ -153,6 +155,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/) void RemoteClient::slotConnected() { + qCInfo(RemoteClientLog) << "Server transport connected"; timeRunning = lastDataReceived = 0; timer->start(); @@ -166,6 +169,12 @@ void RemoteClient::slotConnected() void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentification &event) { + // Identification arrives before login, including when protocol versions differ. + // Quote the server-supplied strings in the log and limit their size. + const QString serverName = QString::fromStdString(event.server_name()).left(256); + const QString serverVersion = QString::fromStdString(event.server_version()).left(256); + qCInfo(RemoteClientLog) << "Server identification:" << serverName + << "version" << serverVersion << "protocol version" << event.protocol_version(); if (event.protocol_version() != protocolVersion) { emit protocolVersionMismatch(protocolVersion, event.protocol_version()); setStatus(StatusDisconnecting); @@ -698,6 +707,8 @@ void RemoteClient::sendCommandContainer(const CommandContainer &cont) void RemoteClient::connectToHost(const QString &hostname, unsigned int port) { usingWebSocket = port == 443 || port == 80 || port == 4748 || port == 8080; + qCInfo(RemoteClientLog) << "Connecting to server" << hostname << "port" << port + << (usingWebSocket ? "using WebSocket" : "using TCP"); if (usingWebSocket) { QUrl url(QString("%1://%2:%3/servatrice").arg(port == 443 ? "wss" : "ws").arg(hostname).arg(port)); websocket->open(url); From dc4aa19d23fa24dbbd61a7522d9baaee4ce695dd Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 04:40:28 -0500 Subject: [PATCH 196/247] Import Moxfield signature spells into the deck [skip ci] (#7751) Co-authored-by: Codex --- .../parsers/interface_json_deck_parser.h | 26 ++++++ .../libcockatrice/deck_list/deck_list.cpp | 19 ++++- .../libcockatrice/deck_list/deck_list.h | 8 ++ tests/CMakeLists.txt | 13 +++ tests/moxfield_signature_spells_test.cpp | 84 +++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 tests/moxfield_signature_spells_test.cpp diff --git a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h index 8f1defb7a..7e15d472f 100644 --- a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h +++ b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h @@ -54,7 +54,11 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.setName(deckName); deckList.setComments(deckDescription); @@ -100,7 +104,11 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.setName(deckName); deckList.setComments(deckDescription); @@ -119,6 +127,24 @@ public: } } + // Moxfield stores Oathbreaker signature spells in their own board, + // separate from both mainboard and commanders. They are playable deck + // cards, but must not replace the Oathbreaker as the banner card. + const QJsonObject signatureSpells = obj.value("signatureSpells").toObject(); + for (auto it = signatureSpells.begin(); it != signatureSpells.end(); ++it) { + const QJsonObject entry = it.value().toObject(); + const QJsonObject card = entry.value("card").toObject(); + const QString name = card.value("name").toString(); + const int quantity = entry.value("quantity").toInt(); + if (name.isEmpty() || quantity <= 0) { + continue; + } + const QString set = card.value("set").toString().toUpper(); + const QString collectorNumber = card.value("cn").toString(); + const QString providerId = card.value("scryfall_id").toString(); + deckList.addCards(name, quantity, DECK_ZONE_MAIN, -1, set, collectorNumber, providerId); + } + return deckList; } }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 821b53dff..2cd5ef914 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -423,8 +423,23 @@ DecklistCardNode *DeckList::addCard(const QString &cardName, const QString &cardProviderId, bool formatLegal) { - auto node = - tree.addCard(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); + return addCards(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); +} + +DecklistCardNode *DeckList::addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position, + const QString &cardSetName, + const QString &cardSetCollectorNumber, + const QString &cardProviderId, + bool formatLegal) +{ + if (amount <= 0) { + return nullptr; + } + auto node = tree.addCard(cardName, amount, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, + formatLegal); refreshDeckHash(); return node; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 6796e9c33..9d0f20586 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -284,6 +284,14 @@ public: const QString &cardSetCollectorNumber = QString(), const QString &cardProviderId = QString(), const bool formatLegal = true); + DecklistCardNode *addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position = -1, + const QString &cardSetName = QString(), + const QString &cardSetCollectorNumber = QString(), + const QString &cardProviderId = QString(), + const bool formatLegal = true); ///@} /** @name Deck identity */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cccff48c3..6acd225c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -463,6 +463,19 @@ endif() # Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. if(WITH_CLIENT) + cockatrice_add_test( + NAME + moxfield_signature_spells_test + SOURCES + ${VERSION_STRING_CPP} + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_loader/card_node_function.cpp + moxfield_signature_spells_test.cpp + LIBS + libcockatrice_card + libcockatrice_deck_list + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ) cockatrice_add_test( NAME single_instance_manager_test diff --git a/tests/moxfield_signature_spells_test.cpp b/tests/moxfield_signature_spells_test.cpp new file mode 100644 index 000000000..54346a934 --- /dev/null +++ b/tests/moxfield_signature_spells_test.cpp @@ -0,0 +1,84 @@ +#include "client/network/parsers/interface_json_deck_parser.h" + +#include +#include +#include +#include + +TEST(MoxfieldImport, SignatureSpellIsAddedToMainWithoutReplacingCommanderBanner) +{ + // Moxfield v2 board objects map IDs to entries with quantity and card. + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Oathbreaker example", "description": "Notes retained", + "mainboard": {"land": {"quantity": 1, "card": {"name": "Island", "set": "abc", "cn": "1"}}}, + "sideboard": {"side": {"quantity": 1, "card": {"name": "Mountain", "set": "abc", "cn": "2"}}}, + "commanders": {"walker": {"quantity": 1, "card": {"name": "Jace", "set": "abc", "cn": "3", "scryfall_id": "walker-id"}}}, + "signatureSpells": {"spell": {"quantity": 1, "card": {"name": "Lightning Bolt", "set": "xyz", "cn": "4", "scryfall_id": "spell-id"}}} + })json") + .object(); + MoxfieldJsonParser parser; + DeckList deck = parser.parse(input); + int mainSpellCount = 0; + int sideSpellCount = 0; + int mainLands = 0; + int sideLands = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (card->getName() == "Island" && zone->getName() == DECK_ZONE_MAIN) { + mainLands += card->getNumber(); + } + if (card->getName() == "Mountain" && zone->getName() == DECK_ZONE_SIDE) { + sideLands += card->getNumber(); + } + if (card->getName() != "Lightning Bolt") { + return; + } + if (zone->getName() == DECK_ZONE_MAIN) { + mainSpellCount += card->getNumber(); + EXPECT_EQ(card->getCardSetShortName(), "XYZ"); + EXPECT_EQ(card->getCardCollectorNumber(), "4"); + EXPECT_EQ(card->getCardProviderId(), "spell-id"); + } else if (zone->getName() == DECK_ZONE_SIDE) { + sideSpellCount += card->getNumber(); + } + }); + EXPECT_EQ(mainSpellCount, 1); + EXPECT_EQ(sideSpellCount, 0); + EXPECT_EQ(mainLands, 1); + EXPECT_EQ(sideLands, 1); + EXPECT_EQ(deck.getName(), "Oathbreaker example"); + EXPECT_EQ(deck.getComments(), "Notes retained"); + EXPECT_EQ(deck.getBannerCard().name, "Jace"); + EXPECT_EQ(deck.getBannerCard().providerId, "walker-id"); +} + +TEST(MoxfieldImport, QuantityAwareDeckInsertionRetainsCountAndPrinting) +{ + DeckList deck; + auto *card = deck.addCards("Island", 2, DECK_ZONE_MAIN, -1, "ABC", "4", "id"); + ASSERT_NE(card, nullptr); + EXPECT_EQ(card->getNumber(), 2); + EXPECT_EQ(card->getCardProviderId(), "id"); + EXPECT_EQ(deck.addCards("Island", 0, DECK_ZONE_MAIN), nullptr); +} + +TEST(OnlineImport, ArchidektPreservesNameDescriptionAndCards) +{ + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Online deck", "description": "Deck notes", + "cards": [{"quantity": 2, "card": { + "oracleCard": {"name": "Island"}, "edition": {"editioncode": "abc"}, "collectorNumber": "1" + }}] + })json") + .object(); + ArchidektJsonParser parser; + const DeckList deck = parser.parse(input); + int count = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (zone->getName() == DECK_ZONE_MAIN && card->getName() == "Island") { + count += card->getNumber(); + } + }); + EXPECT_EQ(count, 2); + EXPECT_EQ(deck.getName(), "Online deck"); + EXPECT_EQ(deck.getComments(), "Deck notes"); +} From fd2a99e8973f4d44858b39a8539f25f6fe841c7e Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 04:41:47 -0500 Subject: [PATCH 197/247] Respect nonempty Qt hints and explain discovery failures [skip ci] (#7733) --- CMakeLists.txt | 10 ++++++---- cmake/FindQtRuntime.cmake | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a25fe2c6..677ef0c12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,8 @@ if(WIN32 OR USE_VCPKG) ) # Qt path set by user or env var if(QTDIR + OR QTDIR32 + OR QTDIR64 OR DEFINED ENV{QTDIR} OR DEFINED ENV{QTDIR32} OR DEFINED ENV{QTDIR64} @@ -236,13 +238,13 @@ else() set(_lib_suffix 32) endif() -if(DEFINED QTDIR${_lib_suffix}) +if(NOT "${QTDIR${_lib_suffix}}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "${QTDIR${_lib_suffix}}") -elseif(DEFINED QTDIR) +elseif(NOT "${QTDIR}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "${QTDIR}") -elseif(DEFINED ENV{QTDIR${_lib_suffix}}) +elseif(NOT "$ENV{QTDIR${_lib_suffix}}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR${_lib_suffix}}") -elseif(DEFINED ENV{QTDIR}) +elseif(NOT "$ENV{QTDIR}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR}") endif() diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 2d74aca4d..7b644276d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -1,6 +1,6 @@ # Find a compatible Qt version # Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE -# Optional Input: QT6_DIR -- Hint as to where Qt6 lives on the system +# Optional Input: Qt6_DIR -- Directory containing Qt6Config.cmake # Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6 # Output: SERVATRICE_QT_MODULES # Output: COCKATRICE_QT_MODULES @@ -57,7 +57,17 @@ find_package( QUIET HINTS ${Qt6_DIR} ) if(NOT Qt6_FOUND) - message(FATAL_ERROR "No suitable version of Qt was found") + set(_QT_DIAGNOSTIC "") + if(Qt6_NOT_FOUND_MESSAGE) + set(_QT_DIAGNOSTIC "\nQt package diagnostic: ${Qt6_NOT_FOUND_MESSAGE}") + endif() + message(FATAL_ERROR + "No suitable version of Qt was found. Qt 6.4.2 or newer with these components is required: " + "${REQUIRED_QT_COMPONENTS};Linguist.\n" + "Set QTDIR or CMAKE_PREFIX_PATH to the Qt installation prefix, or Qt6_DIR to the directory " + "containing Qt6Config.cmake." + "${_QT_DIAGNOSTIC}" + ) endif() set(COCKATRICE_QT_VERSION_NAME Qt6) From 9588e1960d01817997bf8ea39c74fcf51b666ad2 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 05:10:50 -0500 Subject: [PATCH 198/247] Preserve saved deck formats through database reload and view refresh (#7745) --- .../deck_editor_deck_dock_widget.cpp | 49 +++++++++++-------- .../deck_editor_deck_dock_widget.h | 1 + 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 626a1586f..30fde1595 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -187,10 +188,17 @@ void DeckEditorDeckDockWidget::createDeckDock() formatLabel = new QLabel(this); formatComboBox = new QComboBox(this); + formatComboBox->setObjectName("deckFormatComboBox"); formatComboBox->addItem(tr("Loading Database...")); formatComboBox->setEnabled(false); // Disable until loaded commanderBracketWidget = new CommanderBracketWidget(this); + connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + formatComboBox->setPlaceholderText(QString()); + formatComboBox->setToolTip(QString()); + deckStateManager->setFormat(index >= 0 ? formatComboBox->itemData(index).toString() : QString()); + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); + }); commentsLabel = new QLabel(); commentsLabel->setObjectName("commentsLabel"); @@ -355,6 +363,9 @@ void DeckEditorDeckDockWidget::initializeFormats() { QStringList allFormats = CardDatabaseManager::query()->getAllFormatsWithCount().keys(); + // Rebuilding the list is a view update, not a user edit. In particular, + // clear() emits currentIndexChanged(-1), which must not clear the deck. + const QSignalBlocker blocker(formatComboBox); formatComboBox->clear(); // Remove "Loading Database..." formatComboBox->setEnabled(true); @@ -364,28 +375,25 @@ void DeckEditorDeckDockWidget::initializeFormats() formatComboBox->addItem(formatName, formatName); // store the raw key in itemData } - QString format = deckStateManager->getMetadata().gameFormat; - if (!format.isEmpty()) { - formatComboBox->setCurrentIndex(formatComboBox->findData(format)); - } else { - // Ensure no selection is visible initially - formatComboBox->setCurrentIndex(-1); - } - connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { - QString formatKey; - if (index >= 0) { - formatKey = formatComboBox->itemData(index).toString(); - deckStateManager->setFormat(formatKey); - } else { - deckStateManager->setFormat(""); // clear format if deselected - } - - commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); - }); + syncFormatSelection(); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); } +void DeckEditorDeckDockWidget::syncFormatSelection() +{ + if (!formatComboBox->isEnabled()) { + return; + } + const QSignalBlocker blocker(formatComboBox); + const QString format = deckStateManager->getMetadata().gameFormat; + const int index = format.isEmpty() ? -1 : formatComboBox->findData(format); + const QString unavailable = index < 0 && !format.isEmpty() ? tr("Unavailable: %1").arg(format) : QString(); + formatComboBox->setPlaceholderText(unavailable); + formatComboBox->setToolTip(unavailable); + formatComboBox->setCurrentIndex(index); +} + ExactCard DeckEditorDeckDockWidget::getCurrentCard() { QModelIndex current = deckView->selectionModel()->currentIndex(); @@ -596,9 +604,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() updatePlaymatLabel(); updateHash(); - formatComboBox->blockSignals(true); - formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat)); - formatComboBox->blockSignals(false); + syncFormatSelection(); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); @@ -1042,6 +1048,7 @@ void DeckEditorDeckDockWidget::retranslateUi() commentsLabel->setText(tr("&Comments:")); activeGroupCriteriaLabel->setText(tr("Group by:")); formatLabel->setText(tr("Format:")); + syncFormatSelection(); commanderBracketWidget->retranslateUi(); hashLabel1->setText(tr("Hash:")); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index fe03f02ae..02910e390 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -110,6 +110,7 @@ private: DeckListModel *getModel() const; [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); + void syncFormatSelection(); void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); void addChangeBoardMenu(QMenu *menu, const QString &zoneName); From c72192fd8105d24b21e3b089053418a145b74f71 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:59:32 +0100 Subject: [PATCH 199/247] [Format] Small fixup (#7768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Format] Small fixup. * [CardDB] Stop leaking the loader's mutexes. Both mutexes were heap-allocated through default member initializers and never freed, so every CardDatabase destruction leaked 16 bytes. LeakSanitizer flags this while discovering tests that create the database in main. Hold them by value instead, as the loader is a QObject and therefore neither copyable nor movable. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/card/database/card_database_loader.cpp | 4 ++-- .../libcockatrice/card/database/card_database_loader.h | 4 ++-- .../libcockatrice/network/client/remote/remote_client.cpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 00b21f88e..7ca5ba433 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -83,7 +83,7 @@ LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabas auto startTime = QTime::currentTime(); LoadStatus tempLoadStatus = NotLoaded; if (!path.isEmpty()) { - QMutexLocker locker(loadFromFileMutex); + QMutexLocker locker(&loadFromFileMutex); tempLoadStatus = loadFromFile(path, data); } @@ -102,7 +102,7 @@ LoadStatus CardDatabaseLoader::loadCardDatabases(bool ignoreCache) LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) { - QMutexLocker locker(reloadDatabaseMutex); + QMutexLocker locker(&reloadDatabaseMutex); if (!database) { qCWarning(CardDatabaseLoadingLog) << "Loader has no database pointer"; diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index bfbaa0f51..72aeeff3e 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -177,8 +177,8 @@ private: ICardSetPriorityController *priorityController; /**< Controller for reconstructing set state. */ QList availableParsers; /**< List of available parsers for different formats. */ - QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ - QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ + QBasicMutex loadFromFileMutex; /**< Mutex for single-file loading. */ + QBasicMutex reloadDatabaseMutex; /**< Mutex for reloading entire database. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index df1413dd2..fa75e356a 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -173,8 +173,8 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica // Quote the server-supplied strings in the log and limit their size. const QString serverName = QString::fromStdString(event.server_name()).left(256); const QString serverVersion = QString::fromStdString(event.server_version()).left(256); - qCInfo(RemoteClientLog) << "Server identification:" << serverName - << "version" << serverVersion << "protocol version" << event.protocol_version(); + qCInfo(RemoteClientLog) << "Server identification:" << serverName << "version" << serverVersion + << "protocol version" << event.protocol_version(); if (event.protocol_version() != protocolVersion) { emit protocolVersionMismatch(protocolVersion, event.protocol_version()); setStatus(StatusDisconnecting); From 7768416091197d197807cc335785ae1b23ab59f3 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 22:37:25 -0500 Subject: [PATCH 200/247] Place current deck-editor docks before restoring saved layouts [skip ci] (#7734) --- .../src/interface/widgets/tabs/tab_deck_editor.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp index 7ca179cea..e88b2aad6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp @@ -131,10 +131,16 @@ void TabDeckEditor::loadLayout() LayoutsSettings &layouts = SettingsCache::instance().layouts(); auto layoutState = layouts.getDeckEditorLayoutState(); - if (layoutState.isNull()) { - restartLayout(); - } else { - restoreState(layoutState); + // Start with every current dock placed. Saved layouts from older versions + // cannot describe docks introduced later; restoreState leaves those docks + // in their existing positions while restoring the saved docks, including + // their visibility and floating state. + restartLayout(); + if (!layoutState.isEmpty()) { + if (!restoreState(layoutState)) { + // A corrupt or incompatible state must not leave a partial layout. + restartLayout(); + } restoreGeometry(layouts.getDeckEditorGeometry()); } } From 94f7de42f202799bccaae9735698f0b3746b49ed Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 23:29:51 -0500 Subject: [PATCH 201/247] Fix user card games for offline users and stale requests (#7738) --- cockatrice/CMakeLists.txt | 2 + .../widgets/server/user/user_games_model.cpp | 149 +++++++++++ .../widgets/server/user/user_games_model.h | 63 +++++ .../widgets/server/user/user_info_popup.cpp | 131 +++++---- .../widgets/server/user/user_info_popup.h | 12 +- .../widgets/server/user/user_list_widget.cpp | 9 +- tests/CMakeLists.txt | 12 + tests/user_games_model_test.cpp | 248 ++++++++++++++++++ 8 files changed, 550 insertions(+), 76 deletions(-) create mode 100644 cockatrice/src/interface/widgets/server/user/user_games_model.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_games_model.h create mode 100644 tests/user_games_model_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 642d998a0..ccf2dfcea 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -449,6 +449,8 @@ set(cockatrice_SOURCES src/interface/intents/intent_open_shared_deck.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h + src/interface/widgets/server/user/user_games_model.cpp + src/interface/widgets/server/user/user_games_model.h src/interface/widgets/server/user/user_info_popup.cpp src/interface/widgets/server/user/user_info_popup.h ) diff --git a/cockatrice/src/interface/widgets/server/user/user_games_model.cpp b/cockatrice/src/interface/widgets/server/user/user_games_model.cpp new file mode 100644 index 000000000..b7c728c45 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_games_model.cpp @@ -0,0 +1,149 @@ +#include "user_games_model.h" + +#include +#include +#include +#include +#include +#include +#include + +UserGamesModel::UserGamesModel(AbstractClient *_client, int requestTimeoutMs, QObject *parent) + : QAbstractListModel(parent), client(_client) +{ + timeout.setSingleShot(true); + timeout.setInterval(requestTimeoutMs); + connect(&timeout, &QTimer::timeout, this, [this] { + invalidateRequest(); + setState(State::Error); + }); + connected = client->getStatus() == StatusLoggedIn; + connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) { + // AbstractClient::setStatus emits this signal while holding the (non-recursive) + // status mutex, which getStatus() would try to lock again. Track the emitted + // value here, and read this cache from canRefresh() for the same reason. + connected = status == StatusLoggedIn; + if (!connected) { + invalidateRequest(); + replaceGames({}); + setState(online ? State::Disconnected : State::Offline); + } else if (online) { + refresh(); + } + }); + connect(client, &QObject::destroyed, this, [this] { + client = nullptr; + connected = false; + invalidateRequest(); + replaceGames({}); + setState(online ? State::Disconnected : State::Offline); + }); +} + +int UserGamesModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : games.size(); +} + +QVariant UserGamesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.model() != this || index.column() != 0 || index.row() < 0 || + index.row() >= games.size() || role != GameRole) { + return {}; + } + return QVariant::fromValue(games.at(index.row())); +} + +void UserGamesModel::setUser(const QString &name, bool isOnline) +{ + userName = name; + online = isOnline; + refresh(); +} + +UserGamesModel::State UserGamesModel::getState() const +{ + return state; +} + +bool UserGamesModel::canRefresh() const +{ + return online && !userName.isEmpty() && client && connected && state != State::Loading; +} + +void UserGamesModel::invalidateRequest() +{ + ++requestGeneration; + // The client owns pending commands. Detach our interest without deleting + // an object that may already have moved to the client's thread. + disconnect(responseConnection); + responseConnection = {}; + timeout.stop(); +} + +void UserGamesModel::replaceGames(QList replacement) +{ + beginResetModel(); + games = std::move(replacement); + endResetModel(); +} + +void UserGamesModel::setState(State newState) +{ + state = newState; + emit stateChanged(); +} + +void UserGamesModel::refresh() +{ + invalidateRequest(); + const quint64 request = requestGeneration; + replaceGames({}); + if (!online || userName.isEmpty()) { + setState(State::Offline); + return; + } + if (!client || !connected) { + setState(State::Disconnected); + return; + } + + setState(State::Loading); + // State listeners may select another user or disconnect the client. + if (request != requestGeneration) { + return; + } + + Command_GetGamesOfUser command; + command.set_user_name(userName.toStdString()); + PendingCommand *pending = client->prepareSessionCommand(command); + responseConnection = connect(pending, &PendingCommand::finished, this, + [this, request](const Response &response) { finishRequest(response, request); }); + timeout.start(); + client->sendCommand(pending); +} + +void UserGamesModel::finishRequest(const Response &response, quint64 request) +{ + if (request != requestGeneration) { + return; + } + invalidateRequest(); + const quint64 completion = requestGeneration; + if (response.response_code() != Response::RespOk || !response.HasExtension(Response_GetGamesOfUser::ext)) { + setState(State::Error); + return; + } + + const auto &result = response.GetExtension(Response_GetGamesOfUser::ext); + QList replacement; + replacement.reserve(result.game_list_size()); + for (const auto &game : result.game_list()) { + replacement.append(game); + } + replaceGames(std::move(replacement)); + if (completion != requestGeneration) { + return; + } + setState(games.isEmpty() ? State::Empty : State::Ready); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_games_model.h b/cockatrice/src/interface/widgets/server/user/user_games_model.h new file mode 100644 index 000000000..b8ad71a67 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_games_model.h @@ -0,0 +1,63 @@ +#ifndef COCKATRICE_USER_GAMES_MODEL_H +#define COCKATRICE_USER_GAMES_MODEL_H + +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class Response; + +/** Current user's games and the lifetime of the request that supplies them. */ +class UserGamesModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum class State + { + Offline, + Disconnected, + Loading, + Ready, + Empty, + Error + }; + static constexpr int DefaultRequestTimeoutMs = 15000; + static constexpr int GameRole = Qt::UserRole + 10; + + explicit UserGamesModel(AbstractClient *client, + int requestTimeoutMs = DefaultRequestTimeoutMs, + QObject *parent = nullptr); + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + void setUser(const QString &name, bool isOnline); + void refresh(); + State getState() const; + bool canRefresh() const; + +signals: + void stateChanged(); + +private: + void invalidateRequest(); + void replaceGames(QList replacement); + void finishRequest(const Response &response, quint64 request); + void setState(State newState); + + QPointer client; + QString userName; + bool online = false; + bool connected = false; + State state = State::Offline; + QList games; + quint64 requestGeneration = 0; + QMetaObject::Connection responseConnection; + QTimer timeout; +}; + +#endif // COCKATRICE_USER_GAMES_MODEL_H diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index efb3b2e31..9f864d903 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -6,10 +6,9 @@ #include "../../interface/widgets/tabs/tab_supervisor.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/card/printing/exact_card.h" -#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include "user_games_model.h" #include "user_level.h" #include "user_list_painter.h" @@ -34,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -41,8 +41,6 @@ #include #include #include -#include -#include #include #include #include @@ -52,8 +50,6 @@ #include #include #include -#include -#include #include #include #include @@ -427,6 +423,7 @@ void UserInfoPopup::buildUi() refreshBtn = new QPushButton(QStringLiteral("↻"), this); refreshBtn->setFixedSize(20, 20); refreshBtn->setFlat(true); + refreshBtn->setToolTip(tr("Refresh games")); connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); gh->addWidget(refreshBtn); root->addLayout(gh); @@ -437,7 +434,8 @@ void UserInfoPopup::buildUi() root->addWidget(gamesStatus); // Games list - gamesModel = new QStandardItemModel(this); + gamesModel = new UserGamesModel(client, UserGamesModel::DefaultRequestTimeoutMs, this); + connect(gamesModel, &UserGamesModel::stateChanged, this, &UserInfoPopup::updateGamesDisplay); gamesView = new QListView(this); gamesView->setModel(gamesModel); gamesView->setItemDelegate(new PopupGameDelegate(gamesView)); @@ -645,13 +643,21 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) { - rebuildActionButtons(userInfo, online, isBuddy, isIgnored); - adjustSize(); + currentUserInfo = userInfo; + currentBuddy = isBuddy; + currentIgnored = isIgnored; + if (currentOnline != online) { + setOnline(online); + } else { + refreshHeader(); + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + adjustSize(); + } } void UserInfoPopup::onGamesContextMenu(const QPoint &pos) { - const QModelIndex idx = gamesView->indexAt(pos); + const QPersistentModelIndex idx = gamesView->indexAt(pos); if (!idx.isValid()) { return; } @@ -679,7 +685,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) } const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos)); - if (!chosen) { + if (!chosen || !idx.isValid()) { return; } @@ -716,6 +722,8 @@ void UserInfoPopup::showForUser(const QString &userName, currentUser = userName; currentUserInfo = userInfo; currentOnline = online; + currentBuddy = isBuddy; + currentIgnored = isIgnored; // Header refreshHeader(); @@ -723,88 +731,71 @@ void UserInfoPopup::showForUser(const QString &userName, // Actions rebuildActionButtons(userInfo, online, isBuddy, isIgnored); - // Games list reset - gamesModel->clear(); - gamesView->hide(); - gamesStatus->setText(tr("Loading games…")); - gamesStatus->show(); - // Close button, top right corner, above everything closeBtn->move(PopupWidth - closeBtn->width() - 6, 6); closeBtn->raise(); + gamesModel->setUser(currentUser, currentOnline); adjustSize(); - fetchGames(); } // ── Games fetch ─────────────────────────────────────────────────────────────── void UserInfoPopup::fetchGames() { - if (!client || currentUser.isEmpty()) { - return; - } - - Command_GetGamesOfUser cmd; - cmd.set_user_name(currentUser.toStdString()); - - const QString snapshot = currentUser; - PendingCommand *pend = client->prepareSessionCommand(cmd); - connect(pend, &PendingCommand::finished, this, - [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); - client->sendCommand(pend); + gamesModel->refresh(); } -void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) +void UserInfoPopup::setOnline(bool online) { - if (forUser != currentUser) { - return; // stale response, different user showing now - } - - gamesModel->clear(); - - if (r.response_code() != Response::RespOk) { - gamesStatus->setText(tr("Could not load games.")); - gamesStatus->show(); - gamesView->hide(); + if (currentOnline == online) { return; } + currentOnline = online; + refreshHeader(); + rebuildActionButtons(currentUserInfo, online, currentBuddy, currentIgnored); + gamesModel->setUser(currentUser, online); +} - const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); - if (resp.game_list_size() == 0) { - gamesStatus->setText(tr("No active games.")); - gamesStatus->show(); - gamesView->hide(); - return; +void UserInfoPopup::updateGamesDisplay() +{ + refreshBtn->setEnabled(gamesModel->canRefresh()); + const auto state = gamesModel->getState(); + const bool ready = state == UserGamesModel::State::Ready; + gamesView->setVisible(ready); + gamesStatus->setVisible(!ready); + + switch (state) { + case UserGamesModel::State::Offline: + gamesStatus->setText(tr("User is offline.")); + break; + case UserGamesModel::State::Disconnected: + gamesStatus->setText(tr("Not connected to the server.")); + break; + case UserGamesModel::State::Loading: + gamesStatus->setText(tr("Loading games…")); + break; + case UserGamesModel::State::Empty: + gamesStatus->setText(tr("No active games.")); + break; + case UserGamesModel::State::Error: + gamesStatus->setText(tr("Could not load games.")); + break; + case UserGamesModel::State::Ready: { + // Fit exactly to the number of visible rows, scroll when more than 5. + constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint + constexpr int maxRows = 5; + const int count = gamesModel->rowCount(); + gamesView->setFixedHeight(qMin(count, maxRows) * rowH + 2); + gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + break; + } } - - for (int i = 0; i < resp.game_list_size(); ++i) { - auto *item = new QStandardItem; - item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); - item->setEditable(false); - gamesModel->appendRow(item); - } - - gamesStatus->hide(); - gamesView->show(); - - // Fit exactly to the number of visible rows, scroll when more than 5 - constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint - constexpr int maxRows = 5; - const int count = gamesModel->rowCount(); - const int visible = qMin(count, maxRows); - gamesView->setFixedHeight(visible * rowH + 2); - gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); - adjustSize(); } void UserInfoPopup::refreshGames() { - gamesModel->clear(); - gamesView->hide(); - gamesStatus->setText(tr("Loading games…")); - gamesStatus->show(); fetchGames(); } diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index f2a91d025..1af4ad0e1 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_USER_INFO_POPUP_H #define COCKATRICE_USER_INFO_POPUP_H +#include "user_games_model.h" #include "user_list_painter.h" #include @@ -21,15 +22,13 @@ class QPushButton; class TabSupervisor; class QListView; class QPoint; -class QStandardItemModel; -class Response; template class QMap; // ── Roles ───────────────────────────────────────────────────────────────────── namespace PopupRoles { -constexpr int GameData = Qt::UserRole + 10; +constexpr int GameData = UserGamesModel::GameRole; } // Popup theme @@ -143,6 +142,7 @@ public: void showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); void fetchGames(); + void setOnline(bool online); [[nodiscard]] QString getCurrentUser() const { @@ -199,7 +199,7 @@ protected: private slots: void refreshGames(); - void onGamesReceived(const Response &r, const QString &forUser); + void updateGamesDisplay(); void onGamesContextMenu(const QPoint &pos); private: @@ -218,6 +218,8 @@ private: QString currentUser; ServerInfo_User currentUserInfo; bool currentOnline = false; + bool currentBuddy = false; + bool currentIgnored = false; std::function gameInviteAvailable; UserInfoHeaderWidget *header; @@ -225,7 +227,7 @@ private: QLabel *gamesLabel; QFrame *separator; QListView *gamesView; - QStandardItemModel *gamesModel; + UserGamesModel *gamesModel; QLabel *gamesStatus; QPushButton *closeBtn; QPushButton *refreshBtn; diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index a20b39b82..3cb48c49e 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -617,7 +617,14 @@ void UserListWidget::bind(UserListManager *mgr) connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup); connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen); connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup); - connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen); + connect(manager, &UserListManager::userLeftOnline, this, [this, refreshIfPopupOpen](const QString &name) { + // The online row may already have been removed; the pinned popup must + // still invalidate its games request and display the offline state. + if (userInfoPopup && userInfoPopup->getCurrentUser() == name) { + userInfoPopup->setOnline(false); + } + refreshIfPopupOpen(name); + }); rebuild(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6acd225c2..4d7a02ea4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,6 +178,18 @@ 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) +cockatrice_add_test( + NAME + user_games_model_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp + user_games_model_test.cpp + LIBS + libcockatrice_network_client_abstract + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_network +) # 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. diff --git a/tests/user_games_model_test.cpp b/tests/user_games_model_test.cpp new file mode 100644 index 000000000..33c29d6d4 --- /dev/null +++ b/tests/user_games_model_test.cpp @@ -0,0 +1,248 @@ +#include "interface/widgets/server/user/user_games_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // required by the transitive server library; this suite sends no game commands + +namespace +{ +class GamesClient : public AbstractClient +{ +public: + QList sent; + + GamesClient() + { + setStatus(StatusLoggedIn); + } + + ~GamesClient() override + { + qDeleteAll(pendingCommands); + pendingCommands.clear(); + } + + void changeStatus(ClientStatus status) + { + setStatus(status); + } + + void answer(int request, Response response) + { + response.set_cmd_id(sent.at(request).cmd_id()); + ServerMessage message; + message.set_message_type(ServerMessage::RESPONSE); + *message.mutable_response() = response; + processProtocolItem(message); + } + +protected: + void sendCommandContainer(const CommandContainer &command) override + { + sent.append(command); + } +}; + +Response gamesResponse(std::initializer_list ids) +{ + Response response; + response.set_response_code(Response::RespOk); + auto *games = response.MutableExtension(Response_GetGamesOfUser::ext); + for (int id : ids) { + auto *game = games->add_game_list(); + game->set_game_id(id); + game->set_room_id(7); + } + return response; +} + +using State = UserGamesModel::State; + +TEST(UserGamesModel, OfflineUserDoesNotSendARequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", false); + model.refresh(); + EXPECT_TRUE(client.sent.isEmpty()); + EXPECT_EQ(State::Offline, model.getState()); + EXPECT_FALSE(model.canRefresh()); + EXPECT_EQ(0, model.rowCount()); +} + +TEST(UserGamesModel, PresenceLossInvalidatesTheOutstandingResponse) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + EXPECT_EQ(State::Loading, model.getState()); + EXPECT_FALSE(model.canRefresh()); + model.setUser("Alice", false); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Offline, model.getState()); + EXPECT_EQ(0, model.rowCount()); + + model.setUser("Alice", true); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_TRUE(model.canRefresh()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, SwitchingBackToTheSameUserDoesNotAcceptTheirOldRequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + model.setUser("Bob", true); + model.setUser("Alice", true); + client.answer(2, gamesResponse({3})); + client.answer(0, gamesResponse({1})); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(1, model.rowCount()); + EXPECT_EQ(3, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, RefreshSupersedesThePreviousRequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + model.refresh(); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Loading, model.getState()); + EXPECT_EQ(0, model.rowCount()); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, EmptySuccessAndMissingPayloadAreDifferentStates) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + client.answer(0, gamesResponse({})); + EXPECT_EQ(State::Empty, model.getState()); + EXPECT_TRUE(model.canRefresh()); + + model.refresh(); + Response missingPayload; + missingPayload.set_response_code(Response::RespOk); + client.answer(1, missingPayload); + EXPECT_EQ(State::Error, model.getState()); + EXPECT_TRUE(model.canRefresh()); +} + +TEST(UserGamesModel, FailedRequestsCanBeRetried) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + Response error; + error.set_response_code(Response::RespNameNotFound); + client.answer(0, error); + EXPECT_EQ(State::Error, model.getState()); + model.refresh(); + client.answer(1, gamesResponse({4})); + EXPECT_EQ(State::Ready, model.getState()); +} + +TEST(UserGamesModel, DisconnectRejectsLateRepliesAndReconnectFetchesAgain) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + client.changeStatus(StatusDisconnecting); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Disconnected, model.getState()); + EXPECT_FALSE(model.canRefresh()); + model.refresh(); + EXPECT_EQ(1, client.sent.size()); + client.changeStatus(StatusLoggedIn); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); +} + +TEST(UserGamesModel, ClientDestructionClearsGamesAndDisablesRefresh) +{ + auto client = std::make_unique(); + UserGamesModel model(client.get()); + model.setUser("Alice", true); + client->answer(0, gamesResponse({1})); + client.reset(); + EXPECT_EQ(State::Disconnected, model.getState()); + EXPECT_EQ(0, model.rowCount()); + EXPECT_FALSE(model.canRefresh()); + model.refresh(); + EXPECT_EQ(State::Disconnected, model.getState()); +} + +TEST(UserGamesModel, ResetInvalidatesAnOpenGameActionAndPreservesTheModelContract) +{ + GamesClient client; + UserGamesModel model(&client); + QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal); + model.setUser("Alice", true); + client.answer(0, gamesResponse({1, 2, 3})); + QPersistentModelIndex selected(model.index(1)); + EXPECT_TRUE(selected.isValid()); + model.refresh(); + EXPECT_FALSE(selected.isValid()); + EXPECT_EQ(0, model.rowCount()); + client.answer(1, gamesResponse({4})); + EXPECT_EQ(1, model.rowCount()); +} + +TEST(UserGamesModel, StateListenerCanSelectAnotherUserBeforeTheRequestIsSent) +{ + GamesClient client; + UserGamesModel model(&client); + bool switched = false; + QObject::connect(&model, &UserGamesModel::stateChanged, &model, [&] { + if (!switched && model.getState() == State::Loading) { + switched = true; + model.setUser("Bob", false); + } + }); + model.setUser("Alice", true); + EXPECT_TRUE(client.sent.isEmpty()); + EXPECT_EQ(State::Offline, model.getState()); +} + +TEST(UserGamesModel, TimedOutResponseCannotReplaceARetry) +{ + GamesClient client; + UserGamesModel model(&client, 50); + model.setUser("Alice", true); + QSignalSpy changes(&model, &UserGamesModel::stateChanged); + ASSERT_TRUE(changes.wait(1000)); + ASSERT_EQ(State::Error, model.getState()); + EXPECT_TRUE(model.canRefresh()); + model.refresh(); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Loading, model.getState()); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 624163916f6a7356653ceaafe35c37e624f4e696 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 23:49:31 -0500 Subject: [PATCH 202/247] [Client] Keep Commander Spellbook requests bound to the active deck (#7722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Keep Commander Spellbook requests bound to the active deck [skip ci] * [Client] Restore the bracket info tooltip when idle retranslateUi() used to install the static "Why this bracket?" tooltip, but it now calls displayEstimate(), which returned early while no estimate existed. That left the info button without its idle tooltip. Set the fallback where displayEstimate() owns the button's tooltip. An existing estimate still wins, and a language change refreshes the text. --------- Co-authored-by: Lukas Brübach --- .../commander_bracket_service.cpp | 5 + .../commander_bracket_service.h | 1 + .../commander_bracket_widget.cpp | 101 +++++++++++++++--- .../commander_bracket_widget.h | 8 ++ .../commander_spellbook_api_accessor.cpp | 49 ++++++--- .../commander_spellbook_api_accessor.h | 10 +- .../handle_commander_brackets.cpp | 49 +++++++-- .../handle_commander_brackets.h | 4 +- 8 files changed, 182 insertions(+), 45 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp index 8e0448faa..7b60ed23c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp @@ -26,6 +26,11 @@ quint64 CommanderBracketService::estimateBracket(const DeckList &deck, QObject * return CommanderSpellbookApiAccessor::instance().estimateBracket(deck, requester); } +void CommanderBracketService::cancelEstimate(quint64 requestId) +{ + CommanderSpellbookApiAccessor::instance().cancelEstimate(requestId); +} + void CommanderBracketService::onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id, QObject *requester, const EstimateBracketResult &result) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h index 22962cc95..00debce86 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h @@ -30,6 +30,7 @@ public: static CommanderBracketService &instance(); quint64 estimateBracket(const DeckList &deck, QObject *requester); + void cancelEstimate(quint64 requestId); signals: void estimateFinished(quint64 requestId, QObject *requester, const CommanderBracketEstimate &estimate); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 5279165af..9adda008e 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -28,9 +28,18 @@ template class QSharedPointer; CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent) { + autoEstimateTimer = new QTimer(this); + autoEstimateTimer->setSingleShot(true); + connect(autoEstimateTimer, &QTimer::timeout, this, [this]() { + if (SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled() == + commanderSpellbookIntegrationEnabledIndexAutomatic) { + requestBracketEstimate(); + } + }); bracketLabel = new QLabel(tr("Bracket:"), this); bracketValueLabel = new QLabel(this); + bracketValueLabel->setTextFormat(Qt::PlainText); bracketValueLabel->setText("-"); bracketValueLabel->setObjectName("bracketValueLabel"); @@ -65,18 +74,38 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent this, &CommanderBracketWidget::maybeAutoEstimateBracket); connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::commanderSpellbookIntegrationUseOfficialBracketNamesChanged, this, - &CommanderBracketWidget::maybeAutoEstimateBracket); + &CommanderBracketWidget::displayEstimate); setVisible(false); } +CommanderBracketWidget::~CommanderBracketWidget() +{ + cancelEstimate(); +} + +void CommanderBracketWidget::cancelEstimate() +{ + autoEstimateTimer->stop(); + const quint64 canceledRequest = requestId; + requestId = 0; + CommanderBracketService::instance().cancelEstimate(canceledRequest); + bracketRefreshButton->setEnabled(true); + if (canceledRequest) { + bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip({}); + } +} + void CommanderBracketWidget::setDeck(const QSharedPointer &_deck) { + cancelEstimate(); deck = _deck; - requestId = 0; // invalidate any in-flight estimate for the previous deck + hasEstimate = false; // Reset the displayed bracket bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip({}); bracketInfoButton->setToolTip({}); bracketInfoButton->setEnabled(false); bracketRefreshButton->setEnabled(true); @@ -144,7 +173,7 @@ bool CommanderBracketWidget::promptCommanderSpellbookIntegration() dialog.exec(); // Persist naming choice (if not disabled) - if (clickedButton != disableBtn) { + if (clickedButton == enableBtn || clickedButton == automaticBtn) { bool useOfficial = namingCombo->currentIndex() == 1; SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(useOfficial); } @@ -176,9 +205,20 @@ void CommanderBracketWidget::updateBracketVisibility(bool visible) void CommanderBracketWidget::requestBracketEstimate() { + const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + if (!deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0 || requestId || + (mode != commanderSpellbookIntegrationEnabledIndexEnabled && + mode != commanderSpellbookIntegrationEnabledIndexAutomatic)) { + return; + } + + autoEstimateTimer->stop(); + hasEstimate = false; bracketRefreshButton->setEnabled(false); bracketInfoButton->setEnabled(false); + bracketInfoButton->setToolTip({}); bracketValueLabel->setText(tr("Calculating…")); + bracketValueLabel->setToolTip({}); requestId = CommanderBracketService::instance().estimateBracket(*deck, this); } @@ -191,22 +231,35 @@ void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, return; } + requestId = 0; + hasEstimate = true; + lastEstimate = result; + displayEstimate(); + bracketRefreshButton->setEnabled(true); +} + +void CommanderBracketWidget::displayEstimate() +{ + if (!hasEstimate) { + bracketInfoButton->setToolTip(tr("Why this bracket?")); + return; + } + BracketExplainer explainer; - lastBracketExplanation = explainer.explain(result.rawResult); + lastBracketExplanation = explainer.explain(lastEstimate.rawResult); // Display bracket bracketValueLabel->setText( SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() - ? result.officialName - : result.displayName); - bracketRefreshButton->setEnabled(true); + ? lastEstimate.officialName + : lastEstimate.displayName); // Build tooltip QString tooltip; for (const auto §ion : lastBracketExplanation.sections) { - tooltip += "" + section.title + "
"; + tooltip += "" + section.title.toHtmlEscaped() + "
"; for (const auto &line : section.bulletPoints) { - tooltip += "• " + line + "
"; + tooltip += "• " + line.toHtmlEscaped() + "
"; } tooltip += "
"; } @@ -215,13 +268,17 @@ void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, bracketInfoButton->setEnabled(!tooltip.isEmpty()); } -void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString & /*error*/) +void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString &error) { if (requester != this || id != requestId) { return; } + requestId = 0; + hasEstimate = false; bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip( + QStringLiteral("%1").arg(tr("Unable to estimate bracket: %1").arg(error).toHtmlEscaped())); bracketRefreshButton->setEnabled(true); bracketInfoButton->setToolTip({}); bracketInfoButton->setEnabled(false); @@ -229,6 +286,15 @@ void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *request void CommanderBracketWidget::maybeAutoEstimateBracket() { + if (prompting) { + return; + } + autoEstimateTimer->stop(); + if (!deck) { + cancelEstimate(); + updateBracketVisibility(false); + return; + } const QString formatKey = deck->getGameFormat(); const bool isCommander = (formatKey.compare("commander", Qt::CaseInsensitive) == 0); @@ -236,17 +302,20 @@ void CommanderBracketWidget::maybeAutoEstimateBracket() int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); if (!isCommander || mode == commanderSpellbookIntegrationEnabledIndexDisabled) { + cancelEstimate(); updateBracketVisibility(false); return; } if (mode == commanderSpellbookIntegrationEnabledIndexUnprompted) { - if (prompting) { - return; - } + const auto promptedDeck = deck; prompting = true; const bool accepted = promptCommanderSpellbookIntegration(); prompting = false; + if (deck != promptedDeck || !deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0) { + maybeAutoEstimateBracket(); + return; + } if (!accepted) { updateBracketVisibility(false); return; @@ -261,17 +330,17 @@ void CommanderBracketWidget::maybeAutoEstimateBracket() } // Avoid firing if we already have a result or a request in flight - if (!bracketRefreshButton->isEnabled()) { + if (requestId || hasEstimate) { return; } // Defer to avoid races during init / model rebuild - QTimer::singleShot(0, this, &CommanderBracketWidget::requestBracketEstimate); + autoEstimateTimer->start(0); } void CommanderBracketWidget::retranslateUi() { bracketLabel->setText(tr("Bracket:")); - bracketInfoButton->setToolTip(tr("Why this bracket?")); + displayEstimate(); bracketRefreshButton->setToolTip(tr("Recalculate bracket")); } diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h index f5b2b7958..0ef1eedee 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_COMMANDER_BRACKET_WIDGET_H #define COCKATRICE_COMMANDER_BRACKET_WIDGET_H +#include "commander_bracket_service.h" #include "commander_spellbook_bracket_explainer.h" #include @@ -11,6 +12,7 @@ class QLabel; class QToolButton; +class QTimer; struct CommanderBracketEstimate; class DeckList; class QObject; @@ -22,6 +24,7 @@ class CommanderBracketWidget : public QWidget public: explicit CommanderBracketWidget(QWidget *parent = nullptr); + ~CommanderBracketWidget() override; void setDeck(const QSharedPointer &_deck); void retranslateUi(); @@ -35,10 +38,15 @@ private slots: private: bool promptCommanderSpellbookIntegration(); void updateBracketVisibility(bool visible); + void cancelEstimate(); + void displayEstimate(); QSharedPointer deck; bool prompting = false; quint64 requestId = 0; + QTimer *autoEstimateTimer; + bool hasEstimate = false; + CommanderBracketEstimate lastEstimate; QLabel *bracketLabel; QLabel *bracketValueLabel; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp index bd8d9379d..8456e2e21 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp @@ -9,9 +9,10 @@ #include #include #include +#include #include #include -#include +#include #include class DeckList; @@ -24,13 +25,18 @@ CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance() return instance; } -CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) : QObject(parent) +CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) + : QObject(parent), network(NetworkManagerFactory::create(this)) { } CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimateBracket(const DeckList &deck, QObject *requester) { + if (!requester) { + return 0; + } + CommanderSpellbookDeckRequest deckRequest = CommanderSpellbookDeckRequest::fromDeckList(deck); QJsonDocument doc(deckRequest.toJson()); @@ -40,30 +46,39 @@ CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimate req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - QNetworkReply *reply = network.post(req, body); + QNetworkReply *reply = network->post(req, body); const RequestId id = nextRequestId++; - reply->setProperty("requestId", QVariant::fromValue(id)); - reply->setProperty("requester", QVariant::fromValue(requester)); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { onEstimateReplyFinished(reply); }); + pendingRequests.insert(id, reply); + const QPointer guardedRequester(requester); + connect(requester, &QObject::destroyed, reply, [this, id]() { cancelEstimate(id); }); + connect(reply, &QNetworkReply::finished, this, [this, reply, id, guardedRequester]() { + if (pendingRequests.take(id) != reply) { + return; + } + reply->deleteLater(); + if (guardedRequester) { + onEstimateReplyFinished(reply, id, guardedRequester); + } + }); return id; } -void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply) +void CommanderSpellbookApiAccessor::cancelEstimate(RequestId id) { - reply->deleteLater(); - - const RequestId id = reply->property("requestId").toULongLong(); - QObject *requester = reply->property("requester").value(); - - if (!requester) { - // Requester died — silently drop + const QPointer reply = pendingRequests.take(id); + if (!reply) { return; } + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); +} +void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester) +{ if (reply->error() != QNetworkReply::NoError) { emit estimateBracketError(id, requester, reply->errorString()); return; @@ -78,6 +93,10 @@ void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply } EstimateBracketResult result = EstimateBracketResult::fromJson(doc.object()); + if (result.bracketTag.trimmed().isEmpty()) { + emit estimateBracketError(id, requester, QStringLiteral("Missing bracket in response")); + return; + } emit estimateBracketFinished(id, requester, result); } diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h index 98e7e3710..a1b2936bb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h @@ -1,14 +1,16 @@ #ifndef COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H #define COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H -#include +#include #include +#include #include #include #include class DeckList; class QNetworkReply; +class QNetworkAccessManager; struct EstimateBracketResult; class CommanderSpellbookApiAccessor final : public QObject @@ -21,6 +23,7 @@ public: using RequestId = quint64; RequestId estimateBracket(const DeckList &deck, QObject *requester); + void cancelEstimate(RequestId id); signals: void estimateBracketFinished(RequestId id, QObject *requester, const EstimateBracketResult &result); @@ -31,9 +34,10 @@ private: explicit CommanderSpellbookApiAccessor(QObject *parent = nullptr); Q_DISABLE_COPY_MOVE(CommanderSpellbookApiAccessor) - void onEstimateReplyFinished(QNetworkReply *reply); + void onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester); - QNetworkAccessManager network; + QNetworkAccessManager *network; + QHash> pendingRequests; RequestId nextRequestId = 1; }; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp index 407f53e99..be490ce37 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -3,11 +3,14 @@ #include "../../../../../client/settings/cache_settings.h" #include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -22,6 +25,14 @@ HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) { } +HandleCommanderBrackets::~HandleCommanderBrackets() +{ + if (reply) { + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + } +} + void HandleCommanderBrackets::downloadBracketDefinitions() { if (reply) { @@ -35,31 +46,49 @@ void HandleCommanderBrackets::downloadBracketDefinitions() void HandleCommanderBrackets::actFinishParsingDownloadedData() { - reply = qobject_cast(sender()); + auto *finishedReply = qobject_cast(sender()); + if (!finishedReply || finishedReply != reply) { + return; + } - if (reply->error() != QNetworkReply::NoError) { - emit sigBracketDefinitionsDownloadFailed(reply->error()); + // Clear the active request before notifying listeners, which may immediately retry. + reply = nullptr; + finishedReply->deleteLater(); - reply->deleteLater(); + if (finishedReply->error() != QNetworkReply::NoError) { + emit sigBracketDefinitionsDownloadFailed(finishedReply->error()); return; } QJsonParseError parseError; - auto document = QJsonDocument::fromJson(reply->readAll(), &parseError); + auto document = QJsonDocument::fromJson(finishedReply->readAll(), &parseError); - if (parseError.error != QJsonParseError::NoError) { + if (parseError.error != QJsonParseError::NoError || !document.isObject() || + !document.object().value("brackets").isArray() || document.object().value("brackets").toArray().isEmpty()) { emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); - - reply->deleteLater(); return; } + // Validate the complete replacement before touching the last usable definitions. + // Tags are QSettings group names, so path separators must not create nested groups. + QSet tags; + for (const auto &value : document.object().value("brackets").toArray()) { + const auto definition = value.toObject(); + const auto tag = definition.value("tag").toString(); + if (!value.isObject() || tag.trimmed().isEmpty() || tag.contains('/') || tag.contains('\\') || + tags.contains(tag) || definition.value("officialName").toString().trimmed().isEmpty() || + definition.value("displayName").toString().trimmed().isEmpty() || + !definition.value("explanation").isString()) { + emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); + return; + } + tags.insert(tag); + } + updateBracketDefinitions(document.toVariant().toMap()); emit sigBracketDefinitionsDownloaded(); - - reply->deleteLater(); } void HandleCommanderBrackets::updateBracketDefinitions(const QVariantMap &jsonMap) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h index 39bddba05..99b15832c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -14,6 +15,7 @@ class HandleCommanderBrackets : public QObject public: explicit HandleCommanderBrackets(QObject *parent = nullptr); + ~HandleCommanderBrackets() override; void downloadBracketDefinitions(); @@ -28,7 +30,7 @@ private: void updateBracketDefinitions(const QVariantMap &jsonMap); QNetworkAccessManager *nam; - QNetworkReply *reply; + QPointer reply; }; #endif // COCKATRICE_HANDLE_COMMANDER_BRACKETS_H From 81a0b9724025e05ef32b3afd7ade66d79a53b513 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:56:32 +0100 Subject: [PATCH 203/247] [Replay] Seek to the exact time of an event (#7610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeline seeks only expose whole timer intervals, so jumping to an event could leave that event unprocessed until the next tick. Add an event-time seek that also applies events at the target timestamp. Co-authored-by: Lukas Brübach --- .../widgets/replay/replay_manager.cpp | 44 ++++++++++++++----- .../interface/widgets/replay/replay_manager.h | 9 ++-- .../widgets/replay/replay_widget.cpp | 11 +++++ .../interface/widgets/replay/replay_widget.h | 7 +++ 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index 8ba0cb653..b01d7f9d6 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -57,7 +57,7 @@ ReplayManager::ReplayManager(QObject *parent, GameReplay *replay) rewindBufferingTimer = new QTimer(this); rewindBufferingTimer->setSingleShot(true); - connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind); + connect(rewindBufferingTimer, &QTimer::timeout, this, [this] { processRewind(); }); } ReplayManager::~ReplayManager() @@ -65,7 +65,7 @@ ReplayManager::~ReplayManager() delete replay; } -void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) +void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime) { // check boundary conditions if (newTime < 0) { @@ -75,15 +75,17 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) newTime = maxTime; } - newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + if (!includeEventsAtTime) { + newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + } const bool isBackwardsSkip = newTime < currentProcessedTime; currentVisualTime = newTime; if (isBackwardsSkip) { - handleBackwardsSkip(doRewindBuffering); + handleBackwardsSkip(doRewindBuffering, includeEventsAtTime); } else { - processNewEvents(FORWARD_SKIP); + processNewEvents(FORWARD_SKIP, includeEventsAtTime); } timeChanged(currentVisualTime); @@ -95,7 +97,7 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind * is processed at the end. When false, the backwards skip will always cause an immediate rewind. */ -void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) +void ReplayManager::handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime) { if (doRewindBuffering) { // We use a one-shot timer to implement the rewind buffering. @@ -105,11 +107,11 @@ void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs()); } else { // otherwise, process the rewind immediately - processRewind(); + processRewind(includeEventsAtTime); } } -void ReplayManager::processRewind() +void ReplayManager::processRewind(bool includeEventsAtTime) { // stop any queued-up rewinds rewindBufferingTimer->stop(); @@ -117,7 +119,7 @@ void ReplayManager::processRewind() // process the rewind currentEvent = 0; emit rewound(); - processNewEvents(BACKWARD_SKIP); + processNewEvents(BACKWARD_SKIP, includeEventsAtTime); } void ReplayManager::replayTimerTimeout() @@ -134,11 +136,13 @@ void ReplayManager::replayTimerTimeout() } /** @brief Processes all unprocessed events up to the current time. */ -void ReplayManager::processNewEvents(PlaybackMode playbackMode) +void ReplayManager::processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime) { currentProcessedTime = currentVisualTime; - while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) { + while (currentEvent < replayTimeline.size() && + (replayTimeline[currentEvent] < currentProcessedTime || + (includeEventsAtTime && replayTimeline[currentEvent] == currentProcessedTime))) { EventProcessingOptions options; // backwards skip => always skip reveal windows @@ -236,6 +240,24 @@ void ReplayManager::setTime(int time) skipToTime(time, false); } +void ReplayManager::setTimeToEvent(int eventIndex) +{ + if (eventIndex < 0 || eventIndex >= replayTimeline.size()) { + return; + } + + // Flush a pending buffered rewind before seeking, otherwise its timer fires after this seek and rebuilds the + // replay state from an outdated currentVisualTime, dropping the event we seek to. Stopping the timer instead + // of processing it is not enough: playback ticks advance currentProcessedTime without rewinding currentEvent, + // so the board could still be ahead of the target. + if (rewindBufferingTimer->isActive()) { + processRewind(); + } + + // Unlike a timeline seek, jumping to an event should apply that event immediately. + skipToTime(replayTimeline.at(eventIndex), false, true); +} + void ReplayManager::skipByAmount(int amount) { skipToTime(currentVisualTime + amount, amount < 0); diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 3849b86ae..34bae5f9a 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -40,10 +40,10 @@ class ReplayManager : public QObject ///< to rewind buffering int currentEvent = 0; ///< current event's index - void skipToTime(int newTime, bool doRewindBuffering); - void handleBackwardsSkip(bool doRewindBuffering); - void processRewind(); - void processNewEvents(PlaybackMode playbackMode); + void skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime = false); + void handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime = false); + void processRewind(bool includeEventsAtTime = false); + void processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime = false); void handleSkipEmptySection(); private slots: @@ -73,6 +73,7 @@ public slots: void startReplay(); void stopReplay(); void setTime(int time); + void setTimeToEvent(int eventIndex); void skipByAmount(int amount); // use a negative amount to skip backwards signals: diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index 07b4e72ce..3f0bf35d9 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -28,6 +28,7 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed); connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished); connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound); + connect(replayManager, &ReplayManager::timeChanged, this, &ReplayWidget::timeChanged); // timeline widget timelineWidget = new ReplayTimelineWidget; @@ -96,6 +97,16 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) refreshShortcuts(); } +const QList &ReplayWidget::getReplayTimeline() const +{ + return replayManager->getReplayTimeline(); +} + +void ReplayWidget::seekToEvent(int eventIndex) +{ + replayManager->setTimeToEvent(eventIndex); +} + void ReplayWidget::replayFinished() { replayPlayButton->setChecked(false); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 0e78442ce..5d23b358d 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -8,6 +8,7 @@ #ifndef REPLAY_WIDGET_H #define REPLAY_WIDGET_H +#include #include #include #include @@ -35,8 +36,14 @@ public: */ ReplayWidget(QWidget *parent, GameReplay *replay); + [[nodiscard]] const QList &getReplayTimeline() const; + +public slots: + void seekToEvent(int eventIndex); + signals: void rewound(); + void timeChanged(int time); void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); private: From 75d7764643b16446f6a01652f0ed8544d31f5746 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 23:59:17 -0500 Subject: [PATCH 204/247] [Client] Keep Archidekt navigation tied to its active request (#7723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Keep Archidekt navigation tied to its active request [skip ci] * [Client] Keep Archidekt listing pagination after a failed navigation Only update list mode and the next-page link when a listing or deck response is accepted, so a failed deck navigation leaves the existing listing scrollable. Retry still re-issues the failed request. --------- Co-authored-by: Lukas Brübach --- .../tabs/api/archidekt/tab_archidekt.cpp | 155 +++++++++++++----- .../tabs/api/archidekt/tab_archidekt.h | 23 ++- 2 files changed, 136 insertions(+), 42 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 7f7a2d3fd..9f7369435 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -60,8 +61,6 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) { // Initialize network networkManager = NetworkManagerFactory::create(this); - networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); - connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson); // Initialize debounce timer searchDebounceTimer = new QTimer(this); @@ -77,6 +76,48 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) getTopDecks(); } +TabArchidekt::~TabArchidekt() +{ + cancelRequest(); +} + +void TabArchidekt::cancelRequest() +{ + const QPointer previousReply = activeReply; + activeReply = nullptr; + isLoadingMore = false; + if (previousReply) { + disconnect(previousReply, nullptr, this, nullptr); + previousReply->abort(); + previousReply->deleteLater(); + } +} + +void TabArchidekt::startRequest(QUrl url, RequestKind kind, int page) +{ + cancelRequest(); + requestUrl = url; + requestKind = kind; + requestedPage = page; + isLoadingMore = true; + retryButton->hide(); + requestStatusLabel->setText(kind == RequestKind::Deck ? tr("Loading deck…") : tr("Loading decks…")); + requestStatusLabel->show(); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + auto *reply = networkManager->get(request); + activeReply = reply; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { processApiJson(reply); }); +} + +void TabArchidekt::showRequestError(const QString &error) +{ + requestStatusLabel->setText(tr("Unable to load decks: %1").arg(error)); + requestStatusLabel->show(); + retryButton->show(); +} + void TabArchidekt::initializeUi() { // Main container @@ -194,6 +235,18 @@ void TabArchidekt::initializeUi() mainLayout->addWidget(primaryToolbar); mainLayout->addWidget(secondaryToolbar); + + auto *requestStatusRow = new QHBoxLayout; + requestStatusLabel = new QLabel(container); + requestStatusLabel->setTextFormat(Qt::PlainText); + requestStatusLabel->setWordWrap(true); + retryButton = new QPushButton(tr("Retry"), container); + retryButton->hide(); + connect(retryButton, &QPushButton::clicked, this, + [this]() { startRequest(requestUrl, requestKind, requestedPage); }); + requestStatusRow->addWidget(requestStatusLabel, 1); + requestStatusRow->addWidget(retryButton); + mainLayout->addLayout(requestStatusRow); mainLayout->addWidget(scrollArea); setCentralWidget(container); @@ -404,6 +457,7 @@ void TabArchidekt::retranslateUi() minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); + retryButton->setText(tr("Retry")); settingsButton->setToolTip(tr("Display Settings")); } @@ -517,82 +571,95 @@ QString TabArchidekt::buildSearchUrl() void TabArchidekt::doSearch() { - // Reset to first page on new search - currentPage = 1; - // We're searching, so we'll be in list mode - isListMode = true; // Don't debounce - only called by explicit user actions now doSearchImmediate(); } void TabArchidekt::doSearchImmediate() { - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + searchDebounceTimer->stop(); + currentPage = 1; + startRequest(QUrl(buildSearchUrl()), RequestKind::Search); } void TabArchidekt::loadNextPage() { - if (isLoadingMore) { + if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) { return; } - isLoadingMore = true; - currentPage++; - - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + // Continue the accepted search, even if the user has edited filters without submitting them. + // Advance currentPage only after this response has been accepted, so failures can retry it. + startRequest(nextPageUrl, RequestKind::NextPage, currentPage + 1); } void TabArchidekt::actNavigatePage(QString url) { - QNetworkRequest request{QUrl(url)}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + searchDebounceTimer->stop(); + startRequest(QUrl(url), RequestKind::Deck); } void TabArchidekt::getTopDecks() { - currentPage = 1; - QNetworkRequest request{QUrl(buildSearchUrl())}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + doSearchImmediate(); } void TabArchidekt::processApiJson(QNetworkReply *reply) { - if (reply->error() != QNetworkReply::NoError) { - isLoadingMore = false; + if (reply != activeReply) { reply->deleteLater(); return; } + activeReply = nullptr; + isLoadingMore = false; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + showRequestError(reply->errorString()); + return; + } QByteArray responseData = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError); - if (!jsonDoc.isObject()) { - isLoadingMore = false; - reply->deleteLater(); + if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) { + showRequestError(tr("Invalid response from Archidekt.")); return; } QJsonObject jsonObj = jsonDoc.object(); - QString responseUrl = reply->url().toString(); - - if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) { + if (requestKind != RequestKind::Deck) { + if (!jsonObj.value("results").isArray() || + (!jsonObj.value("next").isNull() && !jsonObj.value("next").isString())) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + for (const auto &value : jsonObj.value("results").toArray()) { + if (!value.isObject() || value.toObject().value("id").toInt() <= 0) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + } + currentPage = requestedPage; + const QString next = jsonObj.value("next").toString(); + nextPageUrl = next.isEmpty() ? QUrl() : reply->url().resolved(QUrl(next)); + // An empty or self-repeating page cannot make forward progress. + if (jsonObj.value("results").toArray().isEmpty() || nextPageUrl == reply->url()) { + nextPageUrl.clear(); + } + requestStatusLabel->setText( + currentPage == 1 && jsonObj.value("results").toArray().isEmpty() ? tr("No decks found.") : QString()); + requestStatusLabel->setVisible(!requestStatusLabel->text().isEmpty()); processTopDecksResponse(jsonObj); - } else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) { - processDeckResponse(jsonObj); } else { - prettyPrintJson(jsonObj, 4); + if (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) { + showRequestError(tr("Invalid deck from Archidekt.")); + return; + } + requestStatusLabel->hide(); + processDeckResponse(jsonObj); } - - isLoadingMore = false; - reply->deleteLater(); } void TabArchidekt::processTopDecksResponse(QJsonObject reply) @@ -600,8 +667,12 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply) ArchidektDeckListingApiResponse deckData; deckData.fromJson(reply); + // An accepted listing restores list mode; a failed deck navigation leaves the listing usable. + isListMode = true; + // New search → clear everything - if (currentPage == 1) { + if (requestKind == RequestKind::Search) { + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); @@ -626,8 +697,10 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) // We're in single deck mode - disable infinite scroll isListMode = false; + nextPageUrl.clear(); // Clear existing results for single deck view + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index c74380b63..81c9502df 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -8,8 +8,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -55,6 +57,7 @@ public: * connects signals and slots, and triggers an initial fetch of top decks. */ explicit TabArchidekt(TabSupervisor *_tabSupervisor); + ~TabArchidekt() override; /** * @brief Update all UI text to reflect the current language or translation @@ -190,6 +193,17 @@ protected: bool eventFilter(QObject *obj, QEvent *event) override; private: + enum class RequestKind + { + Search, + NextPage, + Deck + }; + + void startRequest(QUrl url, RequestKind kind, int page = 1); + void cancelRequest(); + void showRequestError(const QString &error); + /** * @brief Initialize the main UI layout and toolbars * @@ -236,7 +250,14 @@ private: int currentPage; ///< Current page number for infinite scroll bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads bool isListMode; - ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr; + QPointer activeReply; + RequestKind requestKind = RequestKind::Search; + QUrl requestUrl; + QUrl nextPageUrl; + int requestedPage = 1; + QPointer listingsWidget; + QLabel *requestStatusLabel; + QPushButton *retryButton; // --------------------------------------------------------------------- // Layout Containers From 28597ef716ec9439f15b5aaee61c2a23686cd703 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 00:22:29 -0500 Subject: [PATCH 205/247] Fix Deckstats and TappedOut request lifetime and result handling (#7705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix deck analysis request lifetime, result URLs and form encoding [skip ci] * Preserve unknown cards in deck analysis exports * Share deck analysis URL validation and drop the unused request latch Both analysis interfaces carried a copy of normalizeAnalysisUrl; move it to one header next to them. They are constructed per analysis and analyzeDeck is called once, so the analysisStarted latch was unreachable; remove it from both. --------- Co-authored-by: Lukas Brübach --- .../network/interfaces/deck_analysis_url.h | 42 +++++++ .../interfaces/deck_stats_interface.cpp | 71 +++++++---- .../network/interfaces/deck_stats_interface.h | 3 + .../interfaces/tapped_out_interface.cpp | 110 ++++++++++-------- .../network/interfaces/tapped_out_interface.h | 3 + 5 files changed, 159 insertions(+), 70 deletions(-) create mode 100644 cockatrice/src/client/network/interfaces/deck_analysis_url.h diff --git a/cockatrice/src/client/network/interfaces/deck_analysis_url.h b/cockatrice/src/client/network/interfaces/deck_analysis_url.h new file mode 100644 index 000000000..425056270 --- /dev/null +++ b/cockatrice/src/client/network/interfaces/deck_analysis_url.h @@ -0,0 +1,42 @@ +/** + * @file deck_analysis_url.h + * @ingroup ApiInterfaces + */ + +#ifndef DECK_ANALYSIS_URL_H +#define DECK_ANALYSIS_URL_H + +#include +#include + +/** + * Validates a deck address returned by a deck analysis service. + * + * Only the expected host (optionally "www."-prefixed), HTTPS, default ports and + * URLs without user information are accepted. Legacy HTTP addresses for the same + * service are upgraded to HTTPS in place instead of being rejected. + * + * Relative addresses must be resolved against the response URL by the caller. + * + * @return true when @p url names a deck on @p serviceHost; false otherwise. + */ +inline bool normalizeAnalysisUrl(QUrl &url, const QString &serviceHost) +{ + const QString host = url.host().toLower(); + const bool legacyHttp = url.scheme() == QStringLiteral("http"); + const int defaultPort = legacyHttp ? 80 : 443; + if (!url.isValid() || (!legacyHttp && url.scheme() != QStringLiteral("https")) || !url.userInfo().isEmpty() || + !url.path().startsWith('/') || (url.port() != -1 && url.port() != defaultPort) || + (host != serviceHost && host != QStringLiteral("www.") + serviceHost)) { + return false; + } + // Both services accept HTTPS. Older public metadata may still identify the + // same deck with HTTP; retain its host/path/query while using HTTPS to open it. + if (legacyHttp) { + url.setScheme(QStringLiteral("https")); + url.setPort(-1); + } + return true; +} + +#endif diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index 508f0bdc9..22be120c4 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -1,5 +1,6 @@ #include "deck_stats_interface.h" +#include "deck_analysis_url.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" @@ -10,12 +11,14 @@ #include #include #include +#include #include #include #include #include +#include #include -#include +#include #include #include #include @@ -29,30 +32,56 @@ DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent) connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished); } +void DeckStatsInterface::showError(const QString &message) +{ + // The notice belongs to the originating editor, not this one-shot request. + // Retire the request before opening UI: opening a window may reenter Qt. + QWidget *owner = qobject_cast(parent()); + auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner); + notice->setTextFormat(Qt::PlainText); + notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + notice->setAttribute(Qt::WA_DeleteOnClose); + deleteLater(); + notice->open(); +} + void DeckStatsInterface::queryFinished(QNetworkReply *reply) { - if (reply->error() != QNetworkReply::NoError) { - QMessageBox::critical(nullptr, tr("Error"), reply->errorString()); - reply->deleteLater(); - deleteLater(); + if (replyHandled) { return; } - - QString data(reply->readAll()); + replyHandled = true; + // All terminal paths release the reply before showing UI or invoking an + // external URL handler, either of which may destroy the originating editor. reply->deleteLater(); - - static const QRegularExpression rx("error() != QNetworkReply::NoError) { + showError(reply->errorString()); return; } - QString deckUrl = match.captured(1); - QDesktopServices::openUrl(deckUrl); + const QString data = QString::fromUtf8(reply->readAll()); + static const QRegularExpression rx( + R"(]*\bproperty\s*=\s*(['"])og:url\1)[^>]*\bcontent\s*=\s*(['"])(.*?)\2)", + QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); + const auto match = rx.match(data); + if (!match.hasMatch()) { + showError(tr("The reply from Deckstats did not contain a deck address.")); + return; + } + const QString address = QTextDocumentFragment::fromHtml(match.captured(3)).toPlainText(); + QUrl deckUrl = reply->url().resolved(QUrl(address, QUrl::StrictMode)); + if (address.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("deckstats.net"))) { + showError(tr("The reply from Deckstats contained an invalid deck address.")); + return; + } + + QPointer guard(this); deleteLater(); + if (!QDesktopServices::openUrl(deckUrl) && guard) { + showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1") + .arg(deckUrl.toDisplayString())); + } } void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data) @@ -60,12 +89,8 @@ void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray DeckList deckWithoutTokens; copyDeckWithoutTokens(deck, deckWithoutTokens); - QUrl params; - QUrlQuery urlQuery; - urlQuery.addQueryItem("deck", deckWithoutTokens.writeToString_Plain()); - urlQuery.addQueryItem("decktitle", deck.getName()); - params.setQuery(urlQuery); - data.append(params.query(QUrl::EncodeReserved).toUtf8()); + data.append("deck=" + QUrl::toPercentEncoding(deckWithoutTokens.writeToString_Plain())); + data.append("&decktitle=" + QUrl::toPercentEncoding(deck.getName())); } void DeckStatsInterface::analyzeDeck(const DeckList &deck) @@ -84,7 +109,9 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList { auto copyIfNotAToken = [&destination](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) { + // Missing local card data is not evidence that a card is a token. Keep + // its original name and quantity so the service analyzes the whole deck. + if ((!dbCard || !dbCard->getIsToken()) && node->getName() != DECK_ZONE_MAYBEBOARD) { DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1); addedCard->setNumber(card->getNumber()); } diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.h b/cockatrice/src/client/network/interfaces/deck_stats_interface.h index e7791075c..b70646403 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.h +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.h @@ -11,6 +11,7 @@ #include class QByteArray; +class QString; class QNetworkAccessManager; class QNetworkReply; class DeckList; @@ -20,6 +21,8 @@ class DeckStatsInterface : public QObject Q_OBJECT private: QNetworkAccessManager *manager; + bool replyHandled = false; + void showError(const QString &message); /** * Deckstats doesn't recognize token cards, and instead tries to find the diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 34dd03765..a57877b6a 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -1,5 +1,6 @@ #include "tapped_out_interface.h" +#include "deck_analysis_url.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" @@ -13,15 +14,17 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include -#include #include +#include #include #include #include @@ -35,54 +38,67 @@ TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent) connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished); } +void TappedOutInterface::showError(const QString &message) +{ + // The notice belongs to the originating editor, not this one-shot request. + // Retire the request before opening UI: opening a window may reenter Qt. + QWidget *owner = qobject_cast(parent()); + auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner); + notice->setTextFormat(Qt::PlainText); + notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + notice->setAttribute(Qt::WA_DeleteOnClose); + deleteLater(); + notice->open(); +} + void TappedOutInterface::queryFinished(QNetworkReply *reply) { + if (replyHandled) { + return; + } + replyHandled = true; + // All terminal paths release the reply before showing UI or invoking an + // external URL handler, either of which may destroy the originating editor. + reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { - QMessageBox::critical(nullptr, tr("Error"), reply->errorString()); - reply->deleteLater(); - deleteLater(); + showError(reply->errorString()); return; } - int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (reply->hasRawHeader("Location")) { - /* - * If the reply contains a "Location" header, a relative URL to the deck on TappedOut - * can be extracted from the header. The http status is a 302 "redirect". - */ - QString deckUrl = reply->rawHeader("Location"); - qCInfo(TappedOutInterfaceLog) << "Tappedout: good reply, http status" << httpStatus << "location" << deckUrl; - QDesktopServices::openUrl("https://tappedout.net" + deckUrl); - } else { - /* - * Otherwise, the deck has not been parsed correctly. Error messages can be extracted - * from the html. Css pseudo selector for errors: $("div.alert-danger > ul > li") - */ - QString data(reply->readAll()); - QStringList errorMessageList = {tr("Unable to analyze the deck.")}; - - static const QRegularExpression rx("
(.*?)"); - auto match = rx.match(data); - if (match.hasMatch()) { - QString errors = match.captured(1); - static const QRegularExpression rx2("
  • (.*?)
  • "); - static const QRegularExpression rxremove("<[^>]*>"); - auto matchIterator = rx2.globalMatch(errors); - while (matchIterator.hasNext()) { - auto match2 = matchIterator.next(); - errorMessageList.append(match2.captured(1).remove(rxremove).simplified()); - } + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatus >= 200 && httpStatus < 400 && reply->hasRawHeader("Location")) { + const QByteArray location = reply->rawHeader("Location"); + QUrl deckUrl = reply->url().resolved(QUrl::fromEncoded(location, QUrl::StrictMode)); + if (location.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("tappedout.net"))) { + showError(tr("The reply from TappedOut contained an invalid deck address.")); + return; } - - QString errorMessage = errorMessageList.join("\n"); - qCWarning(TappedOutInterfaceLog) << "Tappedout: bad reply, http status" << httpStatus << "size" << data.size() - << "message" << errorMessage; - - QMessageBox::critical(nullptr, tr("Error"), errorMessage); + QPointer guard(this); + deleteLater(); + if (!QDesktopServices::openUrl(deckUrl) && guard) { + showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1") + .arg(deckUrl.toDisplayString())); + } + return; } - reply->deleteLater(); - deleteLater(); + const QString data = QString::fromUtf8(reply->readAll()); + QStringList errorMessageList = {tr("Unable to analyze the deck on TappedOut.")}; + static const QRegularExpression errorsRx("
    (.*?)", + QRegularExpression::DotMatchesEverythingOption); + const auto match = errorsRx.match(data); + if (match.hasMatch()) { + static const QRegularExpression itemRx("
  • (.*?)
  • ", QRegularExpression::DotMatchesEverythingOption); + auto items = itemRx.globalMatch(match.captured(1)); + while (items.hasNext()) { + const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified(); + if (!error.isEmpty()) { + errorMessageList.append(error); + } + } + } + qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus; + showError(errorMessageList.join('\n')); } void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data) @@ -90,13 +106,9 @@ void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray DeckList mainboard, sideboard; copyDeckSplitMainAndSide(deck, mainboard, sideboard); - QUrl params; - QUrlQuery urlQuery; - urlQuery.addQueryItem("name", deck.getName()); - urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false, true)); - urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false, true)); - params.setQuery(urlQuery); - data.append(params.query(QUrl::EncodeReserved).toUtf8()); + data.append("name=" + QUrl::toPercentEncoding(deck.getName())); + data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true))); + data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true))); } void TappedOutInterface::analyzeDeck(const DeckList &deck) @@ -117,7 +129,9 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi { auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) { + // Exclude confirmed tokens, but preserve cards missing from the local + // database, including newly released cards in an imported deck. + if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) { return; } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.h b/cockatrice/src/client/network/interfaces/tapped_out_interface.h index 615991b07..27e8c2273 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.h +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.h @@ -14,6 +14,7 @@ inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface"); class QByteArray; +class QString; class QNetworkAccessManager; class QNetworkReply; class DeckList; @@ -29,6 +30,8 @@ class TappedOutInterface : public QObject Q_OBJECT private: QNetworkAccessManager *manager; + bool replyHandled = false; + void showError(const QString &message); void copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard); private slots: From 2e0a718cd73ee38bfc0ddcb3ee439092e172cf2c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:43 +0100 Subject: [PATCH 206/247] [Tests] Vendor QtTesting behind an opt-in WITH_GUI_TEST flag (#7693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .gitmodules | 3 ++ CMakeLists.txt | 38 +++++++++++++++++++++++ cockatrice/CMakeLists.txt | 5 +++ cockatrice/resources/config/qtlogging.ini | 1 + external/qttesting | 1 + 5 files changed, 48 insertions(+) create mode 160000 external/qttesting diff --git a/.gitmodules b/.gitmodules index 732fec7ca..e69c8e686 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "doxygen-awesome-css"] path = doc/doxygen/theme url = https://github.com/jothepro/doxygen-awesome-css.git +[submodule "external/qttesting"] + path = external/qttesting + url = https://github.com/Kitware/QtTesting.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 677ef0c12..563f174e8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,8 @@ option(WITH_CLIENT "Build Cockatrice client" ON) option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) # Compile Servatrice option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) +# Compile GUI tests (QtTesting widget-level playback) +option(WITH_GUI_TEST "Build the QtTesting-based GUI test harness" OFF) # Compile tests option(TEST "Build tests" OFF) # Build with AddressSanitizer and UndefinedBehaviorSanitizer @@ -29,6 +31,14 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) +# The GUI test harness lives in a submodule; a checkout without it must still configure. +if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/CMakeLists.txt") + message(WARNING "WITH_GUI_TEST is ON but external/qttesting is missing, so GUI tests are disabled.\n" + "Run 'git submodule update --init external/qttesting' to enable them." + ) + set(WITH_GUI_TEST OFF) +endif() + # Default to "Release" build type # User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call if(DEFINED CMAKE_BUILD_TYPE) @@ -463,6 +473,34 @@ if(WITH_ORACLE) set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) endif() +if(WITH_GUI_TEST) + set(QtTesting_QT_VERSION "6") + # QtTesting registers its own unit tests when BUILD_TESTING is on. They target the + # Qt version the harness was written for and fail on Qt 6.11 (toolbutton + # long-press recording), and they are not Cockatrice's tests, so suppress them + # for the duration of the subdirectory instead of painting ctest red. + if(DEFINED BUILD_TESTING) + set(_cockatrice_saved_build_testing ${BUILD_TESTING}) + endif() + set(BUILD_TESTING + OFF + CACHE BOOL "Build the testing tree." FORCE + ) + add_subdirectory(external/qttesting) + if(DEFINED _cockatrice_saved_build_testing) + set(BUILD_TESTING + ${_cockatrice_saved_build_testing} + CACHE BOOL "Build the testing tree." FORCE + ) + else() + unset(BUILD_TESTING CACHE) + endif() + # cl.exe has no warnings-as-errors here, and it rejects -Wno-error with D8021. + if(NOT MSVC) + target_compile_options(qttesting PRIVATE -Wno-error) + endif() +endif() + if(TEST) include(CTest) add_subdirectory(tests) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ccf2dfcea..79c26916f 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -566,6 +566,11 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) +if(WITH_GUI_TEST) + target_link_libraries(cockatrice PRIVATE QtTesting) + target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST) +endif() + if(UNIX) if(APPLE) set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}") diff --git a/cockatrice/resources/config/qtlogging.ini b/cockatrice/resources/config/qtlogging.ini index 7ac0d9ca4..e4b845af9 100644 --- a/cockatrice/resources/config/qtlogging.ini +++ b/cockatrice/resources/config/qtlogging.ini @@ -10,6 +10,7 @@ # or set = false to disable logging #main = true +#recording = true #qt_translator = true #window_main.* = true #release_channel = true diff --git a/external/qttesting b/external/qttesting new file mode 160000 index 000000000..c7bb5d372 --- /dev/null +++ b/external/qttesting @@ -0,0 +1 @@ +Subproject commit c7bb5d372133e94c75cbdc70b04e039b864ea379 From ee57d740c23e9c3fd5adfc2c57c4980623c18368 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:43 +0100 Subject: [PATCH 207/247] [Tests] Replay XML test files through a headless runner (#7694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/README.md | 52 ++++++++++ Testing/run_test.sh | 118 +++++++++++++++++++++++ Testing/smoke_test.xml | 8 ++ cockatrice/CMakeLists.txt | 3 + cockatrice/src/interface/recording.cpp | 102 ++++++++++++++++++++ cockatrice/src/interface/recording.h | 54 +++++++++++ cockatrice/src/interface/window_main.cpp | 63 +++++++++++- cockatrice/src/interface/window_main.h | 30 ++++++ cockatrice/src/main.cpp | 43 ++++++++- 9 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 Testing/README.md create mode 100755 Testing/run_test.sh create mode 100644 Testing/smoke_test.xml create mode 100644 cockatrice/src/interface/recording.cpp create mode 100644 cockatrice/src/interface/recording.h diff --git a/Testing/README.md b/Testing/README.md new file mode 100644 index 000000000..2a03abd37 --- /dev/null +++ b/Testing/README.md @@ -0,0 +1,52 @@ +# GUI tests + +Widget-level playback tests for the Cockatrice client, driven by +[QtTesting](https://github.com/Kitware/QtTesting) (`external/qttesting`, a submodule). +Each `*_test.xml` file is a list of events that QtTesting replays against the running +application; a test passes when every event is handled without error. + +## Running + +```bash +git submodule update --init external/qttesting # once +cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=0 -DWITH_GUI_TEST=ON +cmake --build build/debug -j 24 + +./Testing/run_test.sh --all # headless, offscreen +./Testing/run_test.sh Testing/smoke_test.xml # one file +./Testing/run_test.sh --visual Testing/smoke_test.xml # shows the window +``` + +`WITH_GUI_TEST` is on by default; a checkout without the submodule configures with a +warning and no harness. `COCKATRICE_BIN` points the runner at another build, and +`TEST_TIMEOUT` (seconds, default 180) bounds a single test — a failing event inside a +modal dialog can otherwise leave the process alive. The client also hard-exits a few +seconds after playback returns, whatever the result. + +The tests are **not** part of `ctest`: they need a card database and a developer's +settings, so they stay a local loop for now. + +## Caveats + +- **Not hermetic.** The client reads the local settings and card database, so a test can + pass on one machine and fail on another. Avoid asserting things that restored + workspace state or user preferences decide (for example the number of open tabs). +- **English labels only.** Menu entries are addressed by their (translated) text, so the + tests expect the source language. Widgets are addressed by objectName paths, which are + stable across translations. +- Actions that open a dialog are followed by a `pause` event: playback continues in the + dialog's own event loop, and the pause gives the UI time to build itself. +- Widget paths include Qt-internal names where a QTabWidget is involved + (`qt_tabwidget_stackedwidget`), and the deck views in a game are indexed + (`tabbedDeckView_0`, `tabbedDeckView_1`). + +## Writing a test + +A test is a `` list. Widgets are named by their path from the main window, e.g. +`MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu`. Useful commands: + +| Command | Arguments | Effect | +|---------|-----------|--------| +| `activate` | action or menu name | triggers a QAction (menus are opened first) | +| `key` | Qt key code | presses that key on the focused widget | +| `pause` | milliseconds | waits, letting the UI settle | diff --git a/Testing/run_test.sh b/Testing/run_test.sh new file mode 100755 index 000000000..8ff3fe028 --- /dev/null +++ b/Testing/run_test.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Run QtTesting XML test files against the Cockatrice build. +# +# Usage: +# ./Testing/run_test.sh [--visual] [--all | ...] +# +# Options: +# --visual Run with native display (shows the GUI window). +# Default is headless (QT_QPA_PLATFORM=offscreen). +# --all Run every *.xml file in Testing/. +# +# Environment: +# COCKATRICE_BIN Path to the client binary (default: build/debug/cockatrice/cockatrice, +# or the .app bundle binary on macOS). +# TEST_TIMEOUT Seconds a single test may run before it is killed (default: 180). +# Uses timeout, or gtimeout from coreutils on macOS. +# +# Requires the debug build to exist at build/debug/cockatrice/cockatrice. + +set -euo pipefail +shopt -s nullglob + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +if [ -n "${COCKATRICE_BIN:-}" ]; then + COCKATRICE="$COCKATRICE_BIN" +elif [ "$(uname -s)" = "Darwin" ]; then + # A macOS build produces an app bundle, not a bare executable. + COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice.app/Contents/MacOS/cockatrice" +else + COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice" +fi +TEST_DIR="$REPO_ROOT/Testing" +TEST_TIMEOUT="${TEST_TIMEOUT:-180}" + +# Stock macOS has no `timeout`; coreutils provides `gtimeout`. Without either the +# runner still works, but a hung test is not killed. +TIMEOUT=() +if command -v timeout >/dev/null 2>&1; then + TIMEOUT=(timeout --foreground "$TEST_TIMEOUT") +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT=(gtimeout --foreground "$TEST_TIMEOUT") +else + echo "Warning: neither timeout nor gtimeout found; tests are not time-limited." >&2 +fi + +if [ ! -f "$COCKATRICE" ]; then + echo "Error: $COCKATRICE not found. Build first:" >&2 + echo " cmake --build build/debug" >&2 + exit 1 +fi + +VISUAL=0 +if [ "${1:-}" = "--visual" ]; then + VISUAL=1 + shift +fi + +if [ "${1:-}" = "--all" ]; then + shift + TEST_FILES=("$TEST_DIR"/*.xml) + if [ ${#TEST_FILES[@]} -eq 0 ]; then + echo "No *.xml files found in $TEST_DIR" >&2 + exit 1 + fi +elif [ $# -ge 1 ]; then + TEST_FILES=("$@") +else + echo "Usage: $0 [--visual] [--all | ...]" >&2 + echo " Run a single test: $0 Testing/smoke_test.xml" >&2 + echo " Run all tests: $0 --all" >&2 + echo " Run visually: $0 --visual Testing/smoke_test.xml" >&2 + echo " Run all visually: $0 --visual --all" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 +FAILED_TESTS=() + +run_one() { + local file="$1" + + local -a cmd=() + if [ "${#TIMEOUT[@]}" -gt 0 ]; then + cmd=("${TIMEOUT[@]}") + fi + cmd+=("$COCKATRICE" --no-connect --test "$file") + + if [ "$VISUAL" -eq 1 ]; then + "${cmd[@]}" + else + env QT_QPA_PLATFORM=offscreen "${cmd[@]}" + fi +} + +for f in "${TEST_FILES[@]}"; do + name="$(basename "$f")" + printf "%-40s " "$name" + if run_one "$f"; then + echo "PASS" + PASS=$((PASS + 1)) + else + echo "FAIL" + FAIL=$((FAIL + 1)) + FAILED_TESTS+=("$name") + fi +done + +TOTAL=$((PASS + FAIL)) +echo "" +echo "Results: $PASS/$TOTAL passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + echo "Failed:" + for t in "${FAILED_TESTS[@]}"; do + echo " $t" + done + exit 1 +fi diff --git a/Testing/smoke_test.xml b/Testing/smoke_test.xml new file mode 100644 index 000000000..a31edbdd4 --- /dev/null +++ b/Testing/smoke_test.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 79c26916f..234c3ce5a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -567,6 +567,9 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) if(WITH_GUI_TEST) + # recording.cpp translates between XML test files and the QtTesting event pipeline; + # it needs the QtTesting headers, so it only exists with the harness. + target_sources(cockatrice PRIVATE src/interface/recording.cpp) target_link_libraries(cockatrice PRIVATE QtTesting) target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST) endif() diff --git a/cockatrice/src/interface/recording.cpp b/cockatrice/src/interface/recording.cpp new file mode 100644 index 000000000..2b11255f9 --- /dev/null +++ b/cockatrice/src/interface/recording.cpp @@ -0,0 +1,102 @@ +#include "recording.h" + +#include "pqEventTypes.h" + +#include +#include +#include +#include + +XMLEventObserver::XMLEventObserver(QObject *parent) : pqEventObserver(parent) +{ +} + +XMLEventObserver::~XMLEventObserver() = default; + +void XMLEventObserver::setStream(QTextStream *stream) +{ + if (xmlStream) { + xmlStream->writeEndElement(); + xmlStream->writeEndDocument(); + xmlStream.reset(); + } + if (Stream) { + *Stream << xmlString; + } + xmlString.clear(); + pqEventObserver::setStream(stream); + if (Stream) { + xmlStream.reset(new QXmlStreamWriter(&xmlString)); + xmlStream->setAutoFormatting(true); + xmlStream->writeStartDocument(); + xmlStream->writeStartElement(QStringLiteral("events")); + } +} + +void XMLEventObserver::onRecordEvent(const QString &widget, + const QString &command, + const QString &arguments, + const int &eventType) +{ + if (!xmlStream) { + return; + } + + xmlStream->writeStartElement(QStringLiteral("event")); + xmlStream->writeAttribute(QStringLiteral("widget"), widget); + if (eventType == pqEventTypes::ACTION_EVENT) { + xmlStream->writeAttribute(QStringLiteral("command"), command); + } else { // pqEventTypes::CHECK_EVENT + xmlStream->writeAttribute(QStringLiteral("property"), command); + } + xmlStream->writeAttribute(QStringLiteral("arguments"), arguments); + xmlStream->writeEndElement(); +} + +XMLEventSource::XMLEventSource(QObject *parent) : pqEventSource(parent) +{ +} + +XMLEventSource::~XMLEventSource() = default; + +void XMLEventSource::setContent(const QString &xmlFileName) +{ + xmlStream.reset(); + + QFile xml(xmlFileName); + if (!xml.open(QIODevice::ReadOnly)) { + qCWarning(RecordingLog) << "Could not read test file" << xmlFileName; + return; + } + + xmlStream.reset(new QXmlStreamReader(xml.readAll())); +} + +int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) +{ + if (!xmlStream) { + return DONE; + } + + while (!xmlStream->atEnd()) { + if (xmlStream->readNext() != QXmlStreamReader::StartElement || xmlStream->name() != QStringLiteral("event")) { + continue; + } + + const QXmlStreamAttributes attributes = xmlStream->attributes(); + eventType = pqEventTypes::ACTION_EVENT; + widget = attributes.value(QStringLiteral("widget")).toString(); + command = attributes.value(QStringLiteral("command")).toString(); + if (command.isEmpty()) { + // A recorded check event carries a property instead of a command. + command = attributes.value(QStringLiteral("property")).toString(); + } + arguments = attributes.value(QStringLiteral("arguments")).toString(); + return SUCCESS; + } + + if (xmlStream->hasError()) { + qCWarning(RecordingLog) << "Malformed test file:" << xmlStream->errorString(); + } + return DONE; +} diff --git a/cockatrice/src/interface/recording.h b/cockatrice/src/interface/recording.h new file mode 100644 index 000000000..a5d813ea9 --- /dev/null +++ b/cockatrice/src/interface/recording.h @@ -0,0 +1,54 @@ +#ifndef COCKATRICE_RECORDING_H +#define COCKATRICE_RECORDING_H + +#include "pqEventObserver.h" +#include "pqEventSource.h" + +#include +#include + +inline Q_LOGGING_CATEGORY(RecordingLog, "recording"); + +class QXmlStreamReader; +class QXmlStreamWriter; + +/** + * @brief Writes a session recording as an XML test file. + * + * The events are buffered in memory and flushed to the stream when recording + * ends, because the stream is handed over once the file name is known. + */ +class XMLEventObserver : public pqEventObserver +{ + QScopedPointer xmlStream; + QString xmlString; + +public: + explicit XMLEventObserver(QObject *parent); + ~XMLEventObserver() override; + +protected: + void setStream(QTextStream *stream) override; + void onRecordEvent(const QString &widget, + const QString &command, + const QString &arguments, + const int &eventType) override; +}; + +/** + * @brief Plays the events of an XML test file back, one at a time. + */ +class XMLEventSource : public pqEventSource +{ + QScopedPointer xmlStream; + +public: + explicit XMLEventSource(QObject *parent); + ~XMLEventSource() override; + +protected: + void setContent(const QString &xmlFileName) override; + int getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) override; +}; + +#endif // COCKATRICE_RECORDING_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index d546676bc..a69f6df87 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -55,6 +55,13 @@ #include "widgets/server/handle_public_servers.h" #include "widgets/settings_page/abstract_settings_page.h" #include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" +#ifdef WITH_GUI_TEST +#include "pqEventObserver.h" +#include "pqEventSource.h" +#include "pqEventTypes.h" +#include "pqTestUtility.h" +#include "recording.h" +#endif #include #include @@ -310,6 +317,7 @@ void MainWindow::actAbout() mb.setIconPixmap( themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); mb.setTextInteractionFlags(Qt::TextBrowserInteraction); + mb.setObjectName("aboutDialog"); mb.exec(); } @@ -348,6 +356,24 @@ void MainWindow::actOpenSettingsFolder() QtUtils::openPathInFileManager(dir); } +#ifdef WITH_GUI_TEST +void MainWindow::actRecord() +{ + QString filename = QFileDialog::getSaveFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)")); + if (!filename.isEmpty()) { + testUtility->recordTests(filename); + } +} + +void MainWindow::actPlayRecording() +{ + QString filename = QFileDialog::getOpenFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)")); + if (!filename.isEmpty()) { + testUtility->playTests(filename); + } +} +#endif + void MainWindow::retranslateUi() { connectionController->refreshWindowTitle(); @@ -392,6 +418,10 @@ void MainWindow::retranslateUi() aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); +#ifdef WITH_GUI_TEST + aRecord->setText(tr("&Record Test...")); + aPlayRecording->setText(tr("&Play Test...")); +#endif aShow->setText(tr("Show/Hide")); @@ -455,6 +485,12 @@ void MainWindow::createActions() connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); aFirstRunWizard = new QAction(this); connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); +#ifdef WITH_GUI_TEST + aRecord = new QAction(this); + connect(aRecord, &QAction::triggered, this, &MainWindow::actRecord); + aPlayRecording = new QAction(this); + connect(aPlayRecording, &QAction::triggered, this, &MainWindow::actPlayRecording); +#endif aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -495,6 +531,7 @@ void MainWindow::createActions() void MainWindow::createMenus() { + menuBar()->setObjectName("menubar"); cockatriceMenu = menuBar()->addMenu(QString()); cockatriceMenu->addAction(aConnect); cockatriceMenu->addAction(aDisconnect); @@ -523,6 +560,7 @@ void MainWindow::createMenus() tabsMenu = menuBar()->addMenu(QString()); helpMenu = menuBar()->addMenu(QString()); + helpMenu->setObjectName("helpMenu"); helpMenu->addAction(aAbout); helpMenu->addAction(aTips); helpMenu->addSeparator(); @@ -535,12 +573,19 @@ void MainWindow::createMenus() helpMenu->addAction(aOpenSettingsFolder); helpMenu->addSeparator(); helpMenu->addAction(aFirstRunWizard); +#ifdef WITH_GUI_TEST + helpMenu->addSeparator(); + helpMenu->addAction(aRecord); + helpMenu->addAction(aPlayRecording); +#endif } MainWindow::MainWindow(QWidget *parent) - : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), + : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), noConnect(false), cardUpdateProcess(nullptr), logviewDialog(nullptr) { + setObjectName("MainWindow"); + connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this, &MainWindow::pixmapCacheSizeChanged); pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); @@ -626,6 +671,13 @@ MainWindow::MainWindow(QWidget *parent) // run startup check async QTimer::singleShot(0, this, &MainWindow::startupConfigCheck); QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); + +#ifdef WITH_GUI_TEST + testUtility = new pqTestUtility(this); + testUtility->setRecordWithDialog(true); + testUtility->addEventObserver("xml", new XMLEventObserver(this)); + testUtility->addEventSource("xml", new XMLEventSource(this)); +#endif } void MainWindow::startupConfigCheck() @@ -780,6 +832,11 @@ void MainWindow::runFirstRunWizard(bool firstRun) */ void MainWindow::applyStartupDestination() { + if (noConnect) { + qCInfo(WindowMainStartupAutoconnectLog) << "Startup destination suppressed (--no-connect)"; + return; + } + // An explicit command-line connect takes precedence over the startup destination. if (!connectTo.isEmpty()) { return; @@ -1049,6 +1106,10 @@ void MainWindow::handleCockatriceLink(const QString &url) void MainWindow::attemptStartupAutoConnect() { + if (noConnect) { + qCInfo(WindowMainStartupAutoconnectLog) << "Auto-connect suppressed (--no-connect)"; + return; + } if (startupAutoConnectAttempted || skipStartupAutoConnect) { return; } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 9e342e4b0..48d093a38 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -48,6 +48,7 @@ class QAction; class QToolButton; class QWidget; +class pqTestUtility; struct LocalGameOptions; inline Q_LOGGING_CATEGORY(WindowMainLog, "window_main"); @@ -104,6 +105,11 @@ private slots: void actUpdate(); void actViewLog(); void actOpenSettingsFolder(); +#ifdef WITH_GUI_TEST + void actRecord(); + void actPlayRecording(); +#endif + void actShow(); void showWindowIfHidden(); void onUrlChainFinished(bool connected); @@ -185,6 +191,9 @@ private: *aReloadCardDatabase; QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, *aViewLog, *aOpenSettingsFolder; +#ifdef WITH_GUI_TEST + QAction *aRecord, *aPlayRecording; +#endif TabSupervisor *tabSupervisor; IntentUrlParser *urlParser; @@ -208,6 +217,7 @@ private: bool skipStartupAutoConnect = false; bool startupAutoConnectAttempted = false; bool firstRunWizardActive = false; + bool noConnect = false; ///< --no-connect: skip every startup connection QProcess *cardUpdateProcess; QByteArray cardUpdateOutputBuffer; QByteArray cardUpdateErrorOutput; ///< diagnostic output collected from the updater's stderr @@ -219,6 +229,9 @@ private: GameReplay *replay; DlgTipOfTheDay *tip; QUrl connectTo; +#ifdef WITH_GUI_TEST + pqTestUtility *testUtility; +#endif public: explicit MainWindow(QWidget *parent = nullptr); @@ -226,6 +239,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + /** + * @brief Suppresses every startup connection: --connect, auto-connect and the + * configured startup destination. Used by the GUI test harness so a + * playback run never dials out. + */ + void setNoConnect(bool value) + { + noConnect = value; + } + // When set, the window's own startup connection (--connect or auto-connect // on first activation) is skipped. Used for activation launches: the intent // chain triggered by a cockatrice:// URL owns the connection, and letting @@ -266,6 +289,13 @@ public: */ void requestInstallerClose(); +#ifdef WITH_GUI_TEST + pqTestUtility *getTestUtility() const + { + return testUtility; + } +#endif + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 829a8ac42..b4dc0610f 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -39,6 +39,10 @@ #include "single_instance_manager.h" #include "version_string.h" +#ifdef WITH_GUI_TEST +#include "pqTestUtility.h" +#endif + #include #include #include @@ -62,10 +66,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -343,9 +349,16 @@ int main(int argc, char *argv[]) parser.addOptions( {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}, - {"simulate-crash", - QCoreApplication::translate("main", - "Show the crash-report dialog (saves a mock file when no DSN is configured)")}}); + {"simulate-crash", QCoreApplication::translate( + "main", "Show the crash-report dialog (saves a mock file when no DSN is configured)")}, + {"no-connect", QCoreApplication::translate("main", "Do not connect to any server on startup")} +#ifdef WITH_GUI_TEST + , + {{"t", "test"}, + QCoreApplication::translate("main", "Play back a QtTesting XML test file and exit"), + "test-file.xml"} +#endif + }); parser.process(app); @@ -483,6 +496,9 @@ int main(int argc, char *argv[]) if (parser.isSet("connect")) { ui.setConnectTo(parser.value("connect")); } + if (parser.isSet("no-connect")) { + ui.setNoConnect(true); + } qCInfo(MainLog) << "MainWindow constructor finished"; ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); @@ -523,6 +539,27 @@ int main(int argc, char *argv[]) handleActivation(file); } +#ifdef WITH_GUI_TEST + if (parser.isSet("test")) { + QString testFile = parser.value("test"); + qCInfo(MainLog) << "Playing test file:" << testFile; + QTimer::singleShot(0, &ui, [&ui, testFile]() { + bool success = ui.getTestUtility()->playTests(testFile); + qCInfo(MainLog) << "Test playback" << (success ? "succeeded" : "FAILED"); + qApp->exit(success ? 0 : 1); + + // An event that fails while a modal dialog is open aborts the dispatcher, + // but the dialog's nested event loop can then keep the application alive + // forever. Hard-exit after a grace period so a red test always terminates. + auto *watchdog = QThread::create([success] { + QThread::sleep(10); + ::_Exit(success ? 0 : 1); + }); + watchdog->start(); + }); + } +#endif + // Connect to future file/URL events from other instances QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) { for (const QString &file : files) { From d2abd645e82c96cb2cf5987cb7a0f80dbd992c4f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:44 +0100 Subject: [PATCH 208/247] [Tests] Name the menus and dialogs the shell GUI tests drive (#7695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/dialog_lifecycle_test.xml | 11 +++++++++++ Testing/menu_open_close_test.xml | 19 +++++++++++++++++++ Testing/nonmodal_manage_sets_test.xml | 6 ++++++ Testing/nonmodal_tip_test.xml | 6 ++++++ Testing/nonmodal_view_log_test.xml | 6 ++++++ Testing/settings_dialog_test.xml | 6 ++++++ .../interface/widgets/dialogs/dlg_connect.cpp | 6 ++++++ .../widgets/dialogs/dlg_edit_tokens.cpp | 1 + .../widgets/dialogs/dlg_manage_sets.cpp | 2 ++ .../widgets/dialogs/dlg_settings.cpp | 3 +++ .../widgets/dialogs/dlg_tip_of_the_day.cpp | 1 + .../interface/widgets/dialogs/dlg_update.cpp | 1 + .../widgets/dialogs/dlg_view_log.cpp | 1 + cockatrice/src/interface/window_main.cpp | 12 ++++++++++++ 14 files changed, 81 insertions(+) create mode 100644 Testing/dialog_lifecycle_test.xml create mode 100644 Testing/menu_open_close_test.xml create mode 100644 Testing/nonmodal_manage_sets_test.xml create mode 100644 Testing/nonmodal_tip_test.xml create mode 100644 Testing/nonmodal_view_log_test.xml create mode 100644 Testing/settings_dialog_test.xml diff --git a/Testing/dialog_lifecycle_test.xml b/Testing/dialog_lifecycle_test.xml new file mode 100644 index 000000000..00510b802 --- /dev/null +++ b/Testing/dialog_lifecycle_test.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Testing/menu_open_close_test.xml b/Testing/menu_open_close_test.xml new file mode 100644 index 000000000..d894b47b5 --- /dev/null +++ b/Testing/menu_open_close_test.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Testing/nonmodal_manage_sets_test.xml b/Testing/nonmodal_manage_sets_test.xml new file mode 100644 index 000000000..5fe2bfc0c --- /dev/null +++ b/Testing/nonmodal_manage_sets_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_tip_test.xml b/Testing/nonmodal_tip_test.xml new file mode 100644 index 000000000..0f4ffc825 --- /dev/null +++ b/Testing/nonmodal_tip_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_view_log_test.xml b/Testing/nonmodal_view_log_test.xml new file mode 100644 index 000000000..3369f1fd5 --- /dev/null +++ b/Testing/nonmodal_view_log_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/settings_dialog_test.xml b/Testing/settings_dialog_test.xml new file mode 100644 index 000000000..2dd9dda0a --- /dev/null +++ b/Testing/settings_dialog_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 1f567989e..e4411ec1d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -33,6 +33,7 @@ class QWidget; DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) { + setObjectName("connectDialog"); previousHostButton = new QRadioButton(tr("Known Hosts"), this); previousHosts = new QComboBox(this); @@ -85,6 +86,11 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) passwordLabel->setBuddy(passwordEdit); passwordEdit->setEchoMode(QLineEdit::Password); + hostEdit->setObjectName("connectHostEdit"); + portEdit->setObjectName("connectPortEdit"); + playernameEdit->setObjectName("connectPlayernameEdit"); + passwordEdit->setObjectName("connectPasswordEdit"); + savePasswordCheckBox = new QCheckBox(tr("&Save password")); autoConnectCheckBox = new QCheckBox(tr("A&uto connect")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index cbf61a394..3845029f7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -40,6 +40,7 @@ class QWidget; DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr) { + setObjectName("editTokensDialog"); nameLabel = new QLabel(tr("&Name:")); nameEdit = new QLineEdit; nameEdit->setMaxLength(MAX_NAME_LENGTH); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index dbfe38e26..caca5da92 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -48,6 +48,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) { + setObjectName("manageSetsWindow"); setOrderIsSorted = false; // left toolbar @@ -221,6 +222,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) WndSets::~WndSets() { + disconnect(view->header(), &QHeaderView::geometriesChanged, this, &WndSets::saveHeaderState); } void WndSets::closeEvent(QCloseEvent * /*ev*/) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ab870a2c8..3602e67c6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -73,6 +73,8 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) resize(calculateDialogSize(hostSize, available.size(), minimum)); setSizeGripEnabled(true); + setObjectName("settingsDialog"); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); settingsView = new SettingsView(this); @@ -82,6 +84,7 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) buttonBox->addStretch(); okButton = new QPushButton; okButton->setDefault(true); + okButton->setObjectName("settingsOkButton"); connect(okButton, &QPushButton::clicked, this, &DlgSettings::close); buttonBox->addWidget(okButton); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp index 511ff1c7d..c1df0c963 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -28,6 +28,7 @@ DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent) { + setObjectName("tipOfTheDayDialog"); successfulInit = false; QString xmlPath = "theme:tips/tips_of_the_day.xml"; tipDatabase = new TipsOfTheDay(xmlPath, this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index ca55c7b41..11dcd7e27 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -42,6 +42,7 @@ static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent) { + setObjectName("updateDialog"); // Handle layout statusLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp index d55e800a1..e75cf352a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp @@ -18,6 +18,7 @@ class QWidget; DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent) { + setObjectName("viewLogDialog"); logArea = new QPlainTextEdit; logArea->setReadOnly(true); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a69f6df87..13f2e43cd 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -431,24 +431,33 @@ void MainWindow::retranslateUi() void MainWindow::createActions() { aConnect = new QAction(this); + aConnect->setObjectName("actionConnect"); connect(aConnect, &QAction::triggered, this, &MainWindow::actConnect); aDisconnect = new QAction(this); + aDisconnect->setObjectName("actionDisconnect"); aDisconnect->setEnabled(false); connect(aDisconnect, &QAction::triggered, this, &MainWindow::actDisconnect); aSinglePlayer = new QAction(this); + aSinglePlayer->setObjectName("actionSinglePlayer"); connect(aSinglePlayer, &QAction::triggered, this, &MainWindow::actSinglePlayer); aWatchReplay = new QAction(this); + aWatchReplay->setObjectName("actionWatchReplay"); connect(aWatchReplay, &QAction::triggered, this, &MainWindow::actWatchReplay); aFullScreen = new QAction(this); + aFullScreen->setObjectName("actionFullScreen"); aFullScreen->setCheckable(true); connect(aFullScreen, &QAction::toggled, this, &MainWindow::actFullScreen); aRegister = new QAction(this); + aRegister->setObjectName("actionRegister"); connect(aRegister, &QAction::triggered, connectionController, &ConnectionController::registerToServer); aForgotPassword = new QAction(this); + aForgotPassword->setObjectName("actionForgotPassword"); connect(aForgotPassword, &QAction::triggered, connectionController, &ConnectionController::forgotPasswordRequest); aSettings = new QAction(this); + aSettings->setObjectName("actionSettings"); connect(aSettings, &QAction::triggered, this, &MainWindow::actSettings); aExit = new QAction(this); + aExit->setObjectName("actionExit"); connect(aExit, &QAction::triggered, this, &MainWindow::actExit); aManageSets = new QAction(QString(), this); @@ -533,6 +542,7 @@ void MainWindow::createMenus() { menuBar()->setObjectName("menubar"); cockatriceMenu = menuBar()->addMenu(QString()); + cockatriceMenu->setObjectName("cockatriceMenu"); cockatriceMenu->addAction(aConnect); cockatriceMenu->addAction(aDisconnect); cockatriceMenu->addAction(aRegister); @@ -548,6 +558,7 @@ void MainWindow::createMenus() cockatriceMenu->addAction(aExit); dbMenu = menuBar()->addMenu(QString()); + dbMenu->setObjectName("dbMenu"); dbMenu->addAction(aManageSets); dbMenu->addAction(aEditTokens); dbMenu->addSeparator(); @@ -558,6 +569,7 @@ void MainWindow::createMenus() dbMenu->addAction(aReloadCardDatabase); tabsMenu = menuBar()->addMenu(QString()); + tabsMenu->setObjectName("tabsMenu"); helpMenu = menuBar()->addMenu(QString()); helpMenu->setObjectName("helpMenu"); From 34201122ec013426610d2e9e621c6b3b3f5049f1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:44 +0100 Subject: [PATCH 209/247] [Tests] Name the tab, home and settings widgets the GUI tests address (#7696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/deck_editor_open_test.xml | 4 ++ Testing/deck_editor_search_test.xml | 14 ++++++ Testing/integration_test.xml | 45 +++++++++++++++++++ Testing/settings_search_test.xml | 13 ++++++ .../widgets/dialogs/settings_view.cpp | 3 ++ .../interface/widgets/general/home_widget.cpp | 9 ++++ .../src/interface/widgets/tabs/tab_home.cpp | 1 + .../interface/widgets/tabs/tab_supervisor.cpp | 13 ++++++ 8 files changed, 102 insertions(+) create mode 100644 Testing/deck_editor_open_test.xml create mode 100644 Testing/deck_editor_search_test.xml create mode 100644 Testing/integration_test.xml create mode 100644 Testing/settings_search_test.xml diff --git a/Testing/deck_editor_open_test.xml b/Testing/deck_editor_open_test.xml new file mode 100644 index 000000000..437c4bd23 --- /dev/null +++ b/Testing/deck_editor_open_test.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Testing/deck_editor_search_test.xml b/Testing/deck_editor_search_test.xml new file mode 100644 index 000000000..0b9f88fa1 --- /dev/null +++ b/Testing/deck_editor_search_test.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Testing/integration_test.xml b/Testing/integration_test.xml new file mode 100644 index 000000000..5fc7a971a --- /dev/null +++ b/Testing/integration_test.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/settings_search_test.xml b/Testing/settings_search_test.xml new file mode 100644 index 000000000..ba60251f0 --- /dev/null +++ b/Testing/settings_search_test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index d22a8d0b3..96b429a61 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -119,6 +119,8 @@ QWidget *fromWidgetId(WidgetId id) SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false) { + setObjectName("settingsView"); + setupUi(); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi); @@ -135,6 +137,7 @@ void SettingsView::setupUi() searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); + searchEdit->setObjectName("settingsSearchEdit"); auto *searchLayout = new QHBoxLayout; searchLayout->addWidget(searchEdit); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 5905b2c40..5edf607be 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -68,6 +68,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { + setObjectName("homeWidget"); layout = new QGridLayout(this); backgroundSourceCard = new CardInfoPictureArtCropWidget(this); @@ -287,6 +288,7 @@ void HomeWidget::updateButtonsToBackgroundColor() QGroupBox *HomeWidget::createButtons() { QGroupBox *box = new QGroupBox(this); + box->setObjectName("homeButtonsBox"); box->setStyleSheet(R"( QGroupBox { font-size: 20px; @@ -311,27 +313,34 @@ QGroupBox *HomeWidget::createButtons() boxLayout->addSpacing(25); connectButton = new HomeStyledButton(QString(), gradientColors); + connectButton->setObjectName("homeConnectButton"); boxLayout->addWidget(connectButton); visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors); + visualDeckEditorButton->setObjectName("homeCreateNewDeckButton"); connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); }); boxLayout->addWidget(visualDeckEditorButton); visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors); + visualDeckStorageButton->setObjectName("homeBrowseDecksButton"); connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabVisualDeckStorage(true); }); boxLayout->addWidget(visualDeckStorageButton); visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors); + visualDatabaseDisplayButton->setObjectName("homeBrowseCardDatabaseButton"); connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addVisualDatabaseDisplayTab); boxLayout->addWidget(visualDatabaseDisplayButton); edhrecButton = new HomeStyledButton(QString(), gradientColors); + edhrecButton->setObjectName("homeBrowseEdhrecButton"); connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab); boxLayout->addWidget(edhrecButton); archidektButton = new HomeStyledButton(QString(), gradientColors); + archidektButton->setObjectName("homeBrowseArchidektButton"); connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab); boxLayout->addWidget(archidektButton); replayButton = new HomeStyledButton(QString(), gradientColors); + replayButton->setObjectName("homeViewReplaysButton"); connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); boxLayout->addWidget(replayButton); if (qobject_cast(tabSupervisor->parentWidget())) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index af9994fdd..3ad16aa91 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -6,6 +6,7 @@ class TabSupervisor; TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) { + setObjectName("TabHome"); homeWidget = new HomeWidget(this, tabSupervisor); setCentralWidget(homeWidget); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3316232a4..cf71dd7ed 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -157,6 +158,15 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) { + setObjectName("tabSupervisor"); + + // QTabWidget keeps tab pages in an internal stacked widget. Give it a stable + // name so the GUI tests can address the pages by a readable path + // (tabSupervisor/stackedWidget/). + if (auto *stackedWidget = findChild()) { + stackedWidget->setObjectName("stackedWidget"); + } + setElideMode(Qt::ElideRight); setMovable(true); setIconSize(QSize(15, 15)); @@ -462,6 +472,9 @@ void TabSupervisor::addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager auto closeSide = static_cast( tabBar()->style()->styleHint(QStyle::SH_TabBar_CloseButtonPosition, nullptr, tabBar())); auto *closeButton = new CloseButton(tab); + // No object name: the tab bar index shifts when tabs move or close, so an + // index-derived name would go stale (or collide). Callers find the button + // through the tab widget instead. if (manager) { // If managed, all close requests should go through the menu action connect(closeButton, &CloseButton::clicked, this, [manager] { checkAndTrigger(manager, false); }); From 3fa8d78786a41e219d0789cfca418715910b15f2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:45 +0100 Subject: [PATCH 210/247] [Tests] Play card interactions through a Cockatrice event player (#7697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/README.md | 14 + Testing/card_actions_test.xml | 69 ++ Testing/game_mechanics_test.xml | 101 +++ Testing/local_game_lifecycle_test.xml | 178 +++++ cockatrice/CMakeLists.txt | 6 +- .../deckview/deck_view_container.cpp | 10 + .../deckview/tabbed_deck_view_container.cpp | 1 + cockatrice/src/game_graphics/game_view.cpp | 1 + .../pq_cockatrice_event_player.cpp | 628 ++++++++++++++++++ .../pq_cockatrice_event_player.h | 91 +++ .../dialogs/dlg_load_deck_from_clipboard.cpp | 4 + .../dialogs/dlg_local_game_options.cpp | 8 + .../src/interface/widgets/tabs/tab_game.cpp | 9 + cockatrice/src/interface/window_main.cpp | 2 + 14 files changed, 1119 insertions(+), 3 deletions(-) create mode 100644 Testing/card_actions_test.xml create mode 100644 Testing/game_mechanics_test.xml create mode 100644 Testing/local_game_lifecycle_test.xml create mode 100644 cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp create mode 100644 cockatrice/src/game_graphics/pq_cockatrice_event_player.h diff --git a/Testing/README.md b/Testing/README.md index 2a03abd37..cb990ec29 100644 --- a/Testing/README.md +++ b/Testing/README.md @@ -50,3 +50,17 @@ A test is a `` list. Widgets are named by their path from the main windo | `activate` | action or menu name | triggers a QAction (menus are opened first) | | `key` | Qt key code | presses that key on the focused widget | | `pause` | milliseconds | waits, letting the UI settle | +| `clickCard` | `card,zone` | double click the card found in that zone (plays it from hand) | +| `flipCard` | `card,zone` | right-click → *Turn Over* | +| `cardAction` | `card,zone,label` | right-click → first menu entry whose label contains `label` (submenus included) | +| `moveCard` | `card,srcZone,dstZone` | drags the card onto the destination zone | +| `drawArrow` | `srcCard,srcZone,dstCard,dstZone` | right-drag an arrow between two cards | +| `createToken` | `name,color,pt` | creates a token as the active local player | +| `selectItem` | `item label` | selects the tree/list entry with that label | +| `typeText` | `text` | types text one key event at a time | +| `expectProperty` | `property,value` | fails playback unless the widget property matches | + +The card, item and property commands are `pqCockatriceEventPlayer` +(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name +and zone through the `QGraphicsScene` and send real mouse events at the card's current +viewport position, so a test does not depend on the window size or screen resolution. diff --git a/Testing/card_actions_test.xml b/Testing/card_actions_test.xml new file mode 100644 index 000000000..8d6de4c2b --- /dev/null +++ b/Testing/card_actions_test.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/game_mechanics_test.xml b/Testing/game_mechanics_test.xml new file mode 100644 index 000000000..efef78707 --- /dev/null +++ b/Testing/game_mechanics_test.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/local_game_lifecycle_test.xml b/Testing/local_game_lifecycle_test.xml new file mode 100644 index 000000000..bdc964921 --- /dev/null +++ b/Testing/local_game_lifecycle_test.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 234c3ce5a..9b467721d 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -567,9 +567,9 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) if(WITH_GUI_TEST) - # recording.cpp translates between XML test files and the QtTesting event pipeline; - # it needs the QtTesting headers, so it only exists with the harness. - target_sources(cockatrice PRIVATE src/interface/recording.cpp) + # These two translate between XML test files and the QtTesting event pipeline; + # they need the QtTesting headers, so they only exist with the harness. + target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp) target_link_libraries(cockatrice PRIVATE QtTesting) target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST) endif() diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 3bef9e59e..fe63d7a6f 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -71,6 +71,8 @@ void ToggleButton::setState(bool _state) DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) : QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId) { + setObjectName("deckViewContainer"); + loadLocalButton = new QPushButton; loadRemoteButton = new QPushButton; loadFromClipboardButton = new QPushButton; @@ -80,6 +82,14 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) forceStartGameButton = new QPushButton; sideboardLockButton = new ToggleButton; + loadLocalButton->setObjectName("loadLocalButton"); + loadRemoteButton->setObjectName("loadRemoteButton"); + loadFromClipboardButton->setObjectName("loadFromClipboardButton"); + readyStartButton->setObjectName("readyStartButton"); + forceStartGameButton->setObjectName("forceStartGameButton"); + unloadDeckButton->setObjectName("unloadDeckButton"); + sideboardLockButton->setObjectName("sideboardLockButton"); + connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck); connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck); connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard); diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp index 90cbed9a1..75ede0ca8 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp @@ -14,6 +14,7 @@ class TabGame; TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent) : QTabWidget(nullptr), playerId(_playerId), parentGame(parent) { + setObjectName(QString("tabbedDeckView_%1").arg(_playerId)); setTabsClosable(true); connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab); diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index cb59b3d7d..e6d94a035 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -48,6 +48,7 @@ protected: GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0) { + setObjectName("gameView"); setBackgroundBrush(QBrush(QColor(0, 0, 0))); setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing); setViewportUpdateMode(BoundingRectViewportUpdate); diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp new file mode 100644 index 000000000..c6376ef75 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp @@ -0,0 +1,628 @@ +#include "pq_cockatrice_event_player.h" + +#include "../../interface/widgets/tabs/tab_game.h" +#include "board/card_item.h" +#include "game_scene.h" +#include "game_view.h" +#include "zones/card_zone.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer") + +namespace +{ +/** + * Strips Qt's mnemonic markers so a test can name a menu entry by the label the + * user sees ("Turn Over" rather than "T&urn Over"). + */ +QString normalizeActionText(const QString &text) +{ + QString normalized = text; + normalized.remove('&'); + return normalized.simplified(); +} + +/** + * Maps a printable character onto the key a US keyboard would report for it, so + * typing produces the same QKeyEvents a recording would. + */ +Qt::Key keyForCharacter(QChar character) +{ + if (character.isLetter()) { + return static_cast(Qt::Key_A + (character.toUpper().unicode() - 'A')); + } + if (character.isDigit()) { + return static_cast(Qt::Key_0 + (character.unicode() - '0')); + } + switch (character.unicode()) { + case ' ': + return Qt::Key_Space; + case '\n': + return Qt::Key_Return; + case '\t': + return Qt::Key_Tab; + case '-': + return Qt::Key_Minus; + case '_': + return Qt::Key_Underscore; + case '/': + return Qt::Key_Slash; + case ',': + return Qt::Key_Comma; + case '.': + return Qt::Key_Period; + case '\'': + return Qt::Key_Apostrophe; + case ';': + return Qt::Key_Semicolon; + case ':': + return Qt::Key_Colon; + case '"': + return Qt::Key_QuoteDbl; + case '&': + return Qt::Key_Ampersand; + case '(': + return Qt::Key_ParenLeft; + case ')': + return Qt::Key_ParenRight; + case '[': + return Qt::Key_BracketLeft; + case ']': + return Qt::Key_BracketRight; + default: + return Qt::Key_unknown; + } +} + +/** Depth-first search for the first model index whose label matches @p text. */ +QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {}) +{ + for (int row = 0; row < model->rowCount(parent); ++row) { + const QModelIndex index = model->index(row, 0, parent); + if (index.data(Qt::DisplayRole).toString() == text) { + return index; + } + const QModelIndex child = findModelIndexByText(model, text, index); + if (child.isValid()) { + return child; + } + } + return {}; +} + +bool needsShift(QChar character) +{ + if (character.isUpper()) { + return true; + } + return QStringLiteral("_:\"&()").contains(character); +} +} // namespace + +pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent) +{ +} + +bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) +{ + // A known command that fails is a handled-but-failed event: QtTesting logs + // "Unhandled event" when playEvent returns false, so report through `error` + // (the contract playExpectProperty already follows) and still return true. + const auto run = [&error](bool ok) { + if (!ok) { + error = true; + } + return true; + }; + + if (command == "clickCard") { + return run(playClickCard(object, arguments)); + } else if (command == "flipCard") { + return run(playFlipCard(object, arguments)); + } else if (command == "cardAction") { + return run(playCardAction(object, arguments)); + } else if (command == "moveCard") { + return run(playMoveCard(object, arguments)); + } else if (command == "drawArrow") { + return run(playDrawArrow(object, arguments)); + } else if (command == "createToken") { + return run(playCreateToken(object, arguments)); + } else if (command == "selectItem") { + return run(playSelectItem(object, arguments)); + } else if (command == "typeText") { + return run(playTypeText(object, arguments)); + } else if (command == "expectProperty") { + return playExpectProperty(object, arguments, error); + } + return false; +} + +QGraphicsItem * +pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName) +{ + for (QGraphicsItem *gi : scene->items()) { + if (gi->type() != typeCard) { + continue; + } + auto *card = static_cast(gi); + if (!card->getZone() || card->getZone()->getName() != zoneName) { + continue; + } + QString actualName = card->getName(); + if (actualName == cardName || actualName.endsWith(" " + cardName)) { + return card; + } + } + return nullptr; +} + +QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName) +{ + for (QGraphicsItem *gi : scene->items()) { + if (gi->type() != typeZone) { + continue; + } + auto *zone = static_cast(gi); + if (zone->getLogic() && zone->getLogic()->getName() == zoneName) { + return zone; + } + } + return nullptr; +} + +QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText) +{ + if (!menu || actionText.isEmpty()) { + return nullptr; + } + for (QAction *action : menu->actions()) { + if (action->isSeparator()) { + continue; + } + if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) { + return action; + } + if (QMenu *subMenu = action->menu()) { + if (QAction *found = findActionInMenu(subMenu, actionText)) { + return found; + } + } + } + return nullptr; +} + +QMenu *pqCockatriceEventPlayer::visiblePopupMenu() +{ + for (QWidget *w : QApplication::topLevelWidgets()) { + auto *menu = qobject_cast(w); + if (menu && menu->isVisible()) { + return menu; + } + } + return nullptr; +} + +QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item) +{ + return view->mapFromScene(item->sceneBoundingRect().center()); +} +void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view, + QEvent::Type type, + const QPoint &pos, + Qt::MouseButton button, + Qt::MouseButtons buttons) +{ + QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier); + qApp->notify(view->viewport(), &event); +} + +bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView"; + return false; + } + + auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()); + if (!target) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + const QPoint pos = viewportPoint(gv, target); + // A double click is the play gesture from hand: press/release, then the + // double-click event and its release, exactly as a recording produces it. + sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton); + + return true; +} + +bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over")); +} + +bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed()); +} + +bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command, + QGraphicsView *view, + CardItem *card, + const QString &actionText) +{ + const QPoint pos = viewportPoint(view, card); + sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton); + qApp->processEvents(); + + QMenu *menu = visiblePopupMenu(); + if (!menu) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click"; + return false; + } + + QAction *action = findActionInMenu(menu, actionText); + if (!action) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no context menu action matching" << actionText; + menu->close(); + return false; + } + + action->trigger(); + menu->close(); + qApp->processEvents(); + return true; +} + +bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments; + return false; + } + + const QString cardName = parts[0].trimmed(); + const QString srcZoneName = parts[1].trimmed(); + const QString dstZoneName = parts[2].trimmed(); + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene"; + return false; + } + + auto *card = findCardInScene(scene, cardName, srcZoneName); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstZone = findZoneInScene(scene, dstZoneName); + if (!dstZone) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: destination zone" << dstZoneName << "not found"; + return false; + } + + const QPoint srcPos = viewportPoint(gv, card); + const QPoint dstPos = viewportPoint(gv, dstZone); + + // A drag has to travel past Qt's start distance before the scene turns it + // into a card drag item, otherwise the move is interpreted as a click. + const int dragDistance = 2 * QApplication::startDragDistance() + 10; + const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0); + + sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton); + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 4) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments; + return false; + } + + const QString srcCardName = parts[0].trimmed(); + const QString srcZoneName = parts[1].trimmed(); + const QString dstCardName = parts[2].trimmed(); + const QString dstZoneName = parts[3].trimmed(); + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene"; + return false; + } + + auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName); + if (!srcCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName); + if (!dstCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName; + return false; + } + + const QPoint srcPos = viewportPoint(gv, srcCard); + const QPoint dstPos = viewportPoint(gv, dstCard); + + const int dragDistance = 2 * QApplication::startDragDistance() + 10; + const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0); + + sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton); + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: arguments must be 'name,color,pt', got:" << arguments; + return false; + } + + QString tokenName = parts[0].trimmed(); + QString tokenColor = parts[1].trimmed(); + QString tokenPt = parts[2].trimmed(); + + TabGame *tabGame = nullptr; + QWidget *w = gv; + while (w) { + tabGame = qobject_cast(w); + if (tabGame) { + break; + } + w = w->parentWidget(); + } + if (!tabGame) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found"; + return false; + } + + AbstractGame *game = tabGame->getGame(); + if (!game) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game"; + return false; + } + + int activePlayerId = game->getGameState()->getActivePlayer(); + PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId); + if (!player) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no active local player"; + return false; + } + + TokenInfo info; + info.name = tokenName; + info.color = tokenColor; + info.pt = tokenPt; + info.destroy = true; + info.faceDown = false; + + player->getPlayerActions()->actCreateToken(info); + + for (int i = 0; i < 10; ++i) { + qApp->processEvents(); + } + + return true; +} + +bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments) +{ + const QString text = arguments.trimmed(); + if (text.isEmpty()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select"; + return false; + } + + if (auto *tree = qobject_cast(object)) { + QTreeWidgetItemIterator item(tree); + while (*item) { + if ((*item)->text(0) == text) { + tree->setCurrentItem(*item); + qApp->processEvents(); + return true; + } + ++item; + } + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text; + return false; + } + + if (auto *view = qobject_cast(object)) { + const QModelIndex index = findModelIndexByText(view->model(), text); + if (!index.isValid()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text; + return false; + } + view->setCurrentIndex(index); + qApp->processEvents(); + return true; + } + + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: target is neither a tree widget nor an item view"; + return false; +} + +bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments) +{ + auto *widget = qobject_cast(object); + if (!widget) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "typeText: target is not a QWidget"; + return false; + } + + for (const QChar character : arguments) { + const Qt::Key key = keyForCharacter(character); + const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier; + QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character)); + QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character)); + qApp->notify(widget, &press); + qApp->notify(widget, &release); + } + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error) +{ + if (!object) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object"; + error = true; + return true; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments; + error = true; + return true; + } + + const QString propertyName = parts[0].trimmed(); + const QString expected = parts.mid(1).join(','); + + const QVariant actual = object->property(propertyName.toUtf8().constData()); + if (!actual.isValid()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "expectProperty:" << object->objectName() << "has no property" << propertyName; + error = true; + return true; + } + + if (actual.toString() != expected) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is" + << actual.toString() << "but the test expected" << expected; + error = true; + return true; + } + + return true; +} diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.h b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h new file mode 100644 index 000000000..89d88af77 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h @@ -0,0 +1,91 @@ +#ifndef PQ_COCKATRICE_EVENT_PLAYER_H +#define PQ_COCKATRICE_EVENT_PLAYER_H + +#include "pqWidgetEventPlayer.h" + +#include + +class CardItem; +class QAction; +class QGraphicsItem; +class QGraphicsScene; +class QGraphicsView; +class QMenu; + +/** + * Custom event player for Cockatrice game interactions. + * + * Card commands are resolution independent: the card is looked up in the + * QGraphicsScene by name and zone, then mouse events are sent at the card's + * current viewport position. That keeps recordings usable across screen sizes + * and window geometries. + * + * Commands: + * clickCard "cardName,zoneName" + * left press/release + double click — plays a card from hand, taps a + * permanent, opens the deck view entry, depending on the zone + * flipCard "cardName,zoneName" + * right-click → "Turn Over" (shorthand for cardAction) + * cardAction "cardName,zoneName,actionText" + * right-click the card and trigger the first context menu action whose + * label contains actionText; submenus are searched as well, so + * "Add counter (A)" works + * moveCard "cardName,srcZoneName,dstZoneName" + * left-drag the card onto the destination zone — plays a card, moves it + * to the graveyard/exile, puts it back into hand, ... + * drawArrow "srcCard,srcZone,dstCard,dstZone" + * right-drag an arrow from one card to another + * createToken "name,color,pt" + * create a token through the active local player, as the table context + * menu's "Create token" dialog would + * selectItem "item text" + * selects the first item of a tree/list widget whose label matches, for + * navigating the settings navigation tree or similar label-driven views + * typeText "some text" + * types the text into the widget one key event at a time — use it for + * deck lists and search boxes instead of one event per character + * expectProperty "propertyName,expectedValue" + * assertion: playback fails unless object->property(propertyName) + * equals expectedValue — for example "count,2" on the tab widget + */ +class pqCockatriceEventPlayer : public pqWidgetEventPlayer +{ + Q_OBJECT + typedef pqWidgetEventPlayer Superclass; + +public: + explicit pqCockatriceEventPlayer(QObject *parent = nullptr); + + using Superclass::playEvent; + + bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override; + +private: + bool playClickCard(QObject *object, const QString &arguments); + bool playFlipCard(QObject *object, const QString &arguments); + bool playCardAction(QObject *object, const QString &arguments); + bool playMoveCard(QObject *object, const QString &arguments); + bool playDrawArrow(QObject *object, const QString &arguments); + bool playCreateToken(QObject *object, const QString &arguments); + bool playSelectItem(QObject *object, const QString &arguments); + bool playTypeText(QObject *object, const QString &arguments); + bool playExpectProperty(QObject *object, const QString &arguments, bool &error); + + /** @brief Triggers the context menu entry @p actionText after right-clicking @p card. */ + bool invokeCardMenuAction(const char *command, QGraphicsView *view, CardItem *card, const QString &actionText); + + static QGraphicsItem *findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName); + static QGraphicsItem *findZoneInScene(QGraphicsScene *scene, const QString &zoneName); + static QAction *findActionInMenu(QMenu *menu, const QString &actionText); + static QMenu *visiblePopupMenu(); + + /** @brief Viewport position of the item's centre, as the mouse events have to be sent. */ + static QPoint viewportPoint(QGraphicsView *view, const QGraphicsItem *item); + static void sendMouseEvent(QGraphicsView *view, + QEvent::Type type, + const QPoint &pos, + Qt::MouseButton button, + Qt::MouseButtons buttons); +}; + +#endif // PQ_COCKATRICE_EVENT_PLAYER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp index 2c0fd3f6f..c1d0676ca 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp @@ -28,12 +28,16 @@ class QWidget; */ AbstractDlgDeckTextEdit::AbstractDlgDeckTextEdit(QWidget *parent) : QDialog(parent) { + setObjectName("dlgDeckTextEdit"); + contentsEdit = new QPlainTextEdit; + contentsEdit->setObjectName("contentsEdit"); refreshButton = new QPushButton(tr("&Refresh")); connect(refreshButton, &QPushButton::clicked, this, &AbstractDlgDeckTextEdit::actRefresh); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttonBox->setObjectName("buttonBox"); buttonBox->addButton(refreshButton, QDialogButtonBox::ActionRole); connect(buttonBox, &QDialogButtonBox::accepted, this, &AbstractDlgDeckTextEdit::actOK); connect(buttonBox, &QDialogButtonBox::rejected, this, &AbstractDlgDeckTextEdit::reject); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp index 858deb751..3ebb5c093 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp @@ -16,8 +16,11 @@ class QWidget; DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) { + setObjectName("localGameDialog"); + numberPlayersLabel = new QLabel(tr("Players:"), this); numberPlayersEdit = new QSpinBox(this); + numberPlayersEdit->setObjectName("numberPlayersEdit"); numberPlayersEdit->setMinimum(1); numberPlayersEdit->setMaximum(8); numberPlayersEdit->setValue(1); @@ -28,10 +31,12 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) generalGrid->addWidget(numberPlayersLabel, 0, 0); generalGrid->addWidget(numberPlayersEdit, 0, 1); generalGroupBox = new QGroupBox(tr("General"), this); + generalGroupBox->setObjectName("generalGroupBox"); generalGroupBox->setLayout(generalGrid); startingLifeTotalLabel = new QLabel(tr("Starting life total:"), this); startingLifeTotalEdit = new QSpinBox(this); + startingLifeTotalEdit->setObjectName("startingLifeTotalEdit"); startingLifeTotalEdit->setMinimum(-999999999); startingLifeTotalEdit->setMaximum(999999999); startingLifeTotalEdit->setValue(20); @@ -42,11 +47,14 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) gameSetupGrid->addWidget(startingLifeTotalLabel, 0, 0); gameSetupGrid->addWidget(startingLifeTotalEdit, 0, 1); gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"), this); + gameSetupOptionsGroupBox->setObjectName("gameSetupOptionsGroupBox"); gameSetupOptionsGroupBox->setLayout(gameSetupGrid); rememberSettingsCheckBox = new QCheckBox(tr("Remember settings"), this); + rememberSettingsCheckBox->setObjectName("rememberSettingsCheckBox"); buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->setObjectName("buttonBox"); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgLocalGameOptions::actOK); connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgLocalGameOptions::reject); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 9e724727f..cbff850a4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -99,6 +99,7 @@ class Message; TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) { + setObjectName("TabGame"); // THIS CTOR IS USED ON REPLAY game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); @@ -116,6 +117,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) addDockWidget(Qt::BottomDockWidgetArea, replayDock); mainWidget = new QStackedWidget(this); + mainWidget->setObjectName("mainWidget"); mainWidget->addWidget(deckViewContainerWidget); mainWidget->addWidget(gamePlayAreaWidget); setCentralWidget(mainWidget); @@ -144,6 +146,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, const QMap &_roomGameTypes) : Tab(_tabSupervisor), userListProxy(_tabSupervisor->getUserListManager()) { + setObjectName("TabGame"); // THIS CTOR IS USED ON GAMES game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes, &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); @@ -160,6 +163,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); mainWidget = new QStackedWidget(this); + mainWidget->setObjectName("mainWidget"); mainWidget->addWidget(deckViewContainerWidget); mainWidget->addWidget(gamePlayAreaWidget); mainWidget->setContentsMargins(0, 0, 0, 0); @@ -1118,6 +1122,7 @@ void TabGame::createMenuItems() connect(aFocusChat, &QAction::triggered, sayEdit, qOverload<>(&LineEditCompleter::setFocus)); phasesMenu = new TearOffMenu(this); + phasesMenu->setObjectName("phasesMenu"); for (int i = 0; i < phasesToolbar->phaseCount(); ++i) { auto *temp = new QAction(QString(), this); @@ -1128,12 +1133,16 @@ void TabGame::createMenuItems() phasesMenu->addSeparator(); phasesMenu->addAction(aNextPhase); + aNextPhase->setObjectName("aNextPhase"); phasesMenu->addAction(aNextPhaseAction); + aNextPhaseAction->setObjectName("aNextPhaseAction"); gameMenu = new QMenu(this); + gameMenu->setObjectName("gameMenu"); playersSeparator = gameMenu->addSeparator(); gameMenu->addMenu(phasesMenu); gameMenu->addAction(aNextTurn); + aNextTurn->setObjectName("aNextTurn"); gameMenu->addAction(aReverseTurn); gameMenu->addSeparator(); gameMenu->addAction(aRemoveLocalArrows); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 13f2e43cd..79a802234 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -56,6 +56,7 @@ #include "widgets/settings_page/abstract_settings_page.h" #include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" #ifdef WITH_GUI_TEST +#include "../game_graphics/pq_cockatrice_event_player.h" #include "pqEventObserver.h" #include "pqEventSource.h" #include "pqEventTypes.h" @@ -689,6 +690,7 @@ MainWindow::MainWindow(QWidget *parent) testUtility->setRecordWithDialog(true); testUtility->addEventObserver("xml", new XMLEventObserver(this)); testUtility->addEventSource("xml", new XMLEventSource(this)); + testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this)); #endif } From 96f8c3a423931fa96a046f8ed1a2f99abfd56af5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:45 +0100 Subject: [PATCH 211/247] [Tests] Assert settings navigation through the event player (#7698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/settings_navigation_test.xml | 33 +++++++++++++++++++ .../widgets/dialogs/settings_view.cpp | 3 ++ 2 files changed, 36 insertions(+) create mode 100644 Testing/settings_navigation_test.xml diff --git a/Testing/settings_navigation_test.xml b/Testing/settings_navigation_test.xml new file mode 100644 index 000000000..c112966e0 --- /dev/null +++ b/Testing/settings_navigation_test.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 96b429a61..9c0f12979 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -144,6 +144,7 @@ void SettingsView::setupUi() // Pages stacked widget pagesWidget = new QStackedWidget; + pagesWidget->setObjectName("pagesWidget"); auto *generalPage = new GeneralSettingsPage; auto *appearancePage = new AppearanceSettingsPage; @@ -208,6 +209,7 @@ void SettingsView::setupUi() // Pages container (stacked widget + search results overlay) pagesContainer = new QWidget; + pagesContainer->setObjectName("pagesContainer"); auto *containerLayout = new QStackedLayout; containerLayout->setStackingMode(QStackedLayout::StackAll); containerLayout->addWidget(pagesWidget); @@ -247,6 +249,7 @@ void SettingsView::setupUi() void SettingsView::setupNavTree() { navTree = new QTreeWidget; + navTree->setObjectName("navTree"); navTree->setHeaderHidden(true); navTree->setRootIsDecorated(true); navTree->setIconSize(QSize(24, 24)); From 99422c2bf002fab97e3a8ea13f89f072a53ea533 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:06:35 +0100 Subject: [PATCH 212/247] [Client] Stop sending the saved login when its checkbox is unticked (#7713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unticking 'Save password' only disabled auto-connect: the dialog still handed the stored verifier to the connection, so a password typed at the same time was ignored, and a server that still has the legacy hash refused the login before it could upgrade the account. Clear the verifier in effect on untick, restore it on re-tick unless a password has been typed, and drop the stale 'Password saved' hint. The downgrade abort can now point at unticking the box and logging in with the password, without the extra reconnect round-trip. Co-authored-by: Lukas Brübach --- .../interface/widgets/dialogs/dlg_connect.cpp | 22 +++++++++++++++---- .../interface/widgets/dialogs/dlg_connect.h | 4 ++++ .../network/client/remote/remote_client.cpp | 5 +++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index e4411ec1d..853a00828 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -296,11 +296,13 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); storedVerifier.clear(); + savedVerifier.clear(); if (savePasswordStatus) { const QString stored = _data.at(4); if (stored.startsWith("$")) { storedVerifier = stored; + savedVerifier = stored; passwordEdit->clear(); passwordEdit->setPlaceholderText(tr("Password saved")); } else { @@ -337,6 +339,7 @@ void DlgConnect::newHostSelected(bool state) passwordEdit->clear(); passwordEdit->setPlaceholderText(QString()); storedVerifier.clear(); + savedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); saveEdit->setDisabled(false); @@ -352,9 +355,19 @@ void DlgConnect::passwordSaved(QT_STATE_CHANGED_T state) Q_UNUSED(state); if (savePasswordCheckBox->isChecked()) { autoConnectCheckBox->setEnabled(true); + // Re-ticking keeps a password the user has started typing; otherwise restore the saved + // login this dialog was opened with, so unticking and changing your mind is harmless. + if (passwordEdit->text().isEmpty()) { + storedVerifier = savedVerifier; + passwordEdit->setPlaceholderText(savedVerifier.isEmpty() ? QString() : tr("Password saved")); + } } else { autoConnectCheckBox->setChecked(false); autoConnectCheckBox->setEnabled(false); + // Unticking means the password typed below is sent for this connection: stop sending the + // saved verifier instead, and stop claiming a password is saved. + storedVerifier.clear(); + passwordEdit->setPlaceholderText(QString()); } } @@ -362,10 +375,11 @@ void DlgConnect::actOk() { ServersSettings &servers = SettingsCache::instance().servers(); - // Never write a newly typed plaintext password to disk when a verifier is already stored: - // the typed value is used for this connection and a fresh verifier is persisted after a - // successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior. - const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier; + // Never write a newly typed plaintext password to disk when a verifier is available: the + // typed value is used for this connection and a fresh verifier is persisted after a + // successful login. Without any verifier we keep the previous (plaintext legacy) behavior. + const QString verifierToSave = storedVerifier.isEmpty() ? savedVerifier : storedVerifier; + const QString passwordToSave = verifierToSave.isEmpty() ? passwordEdit->text() : verifierToSave; if (newHostButton->isChecked()) { if (saveEdit->text().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index bef5f136e..dad3623f5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -98,6 +98,10 @@ private: QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer; QMap> savedHostList; HandlePublicServers *hps; + //! \brief Saved "$scrypt$..." verifier as loaded for the selected profile, kept so that + //! re-ticking "Save password" after unticking restores it. + QString savedVerifier; + //! \brief Verifier in effect for this connection: empty while "Save password" is unticked. QString storedVerifier; const QString placeHolderText = tr("Downloading..."); }; diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index fa75e356a..636c603c6 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -50,11 +50,12 @@ 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. +// password, which also upgrades the account. Unticking "Save password" drops the verifier +// for the next connection. 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."); + "used. Untick 'Save password' in the Connect window and log in with 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", From 75beb7f302c0b65aa43ae7954660191187c474e7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:22:43 +0100 Subject: [PATCH 213/247] [CI] Run the Linux test suite against the release build (#7665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Run the Linux test suite against the release build A packaging leg compiled the tree twice: once in Debug to run the suite, once in Release to package it. The suite is 655 tests that finish in four seconds, so the Debug build bought nothing the release build cannot provide - macOS and Windows have always tested their release build. The sanitizer and coverage legs keep their Debug build, because that is the configuration their instrumentation is for. Every other leg runs ctest against the release configuration, ahead of the package target, so a failing suite still stops the leg before anything is built into an installer. A leg that only tests, Arch, builds Release without the crash reporting SDK the packaging legs ship with. Debian 13 and Fedora 44 each drop a 15 minute compile from a job that gates a pull request. * [CI] Keep Fedora 44 and Arch on the Debug build Only the Debug configuration passes -Wall -Wextra -Werror, so moving those two legs to the release build would have stopped the newest distro toolchains from failing on a new warning. They keep the Debug compile and test master had; the other Linux legs still test the release build. Addresses review thread PRRT_kwDOACxbl86x2tOP (PR #7665). * [CI] Decide the release test run once per leg The step's if: and its shell both recomputed "does this leg test the release build", and the two could drift into a leg that builds a release it never tests. RUN_TESTS now holds that decision and both read it. It lives at job level because a step's own env is not visible to that step's if:. Addresses review thread PRRT_kwDOACxbl86x2tOV (PR #7665). --------- Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 45 +++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index df054ea26..16610d872 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -141,6 +141,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already + debug_test: yes # see the note on Fedora 44 below - distro: Servatrice_Debian version: 12 @@ -170,6 +171,11 @@ jobs: version: 44 package: RPM + # Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: + # only the Debug configuration passes -Werror, so these two are the legs where a + # new warning in either compiler still fails the build. + debug_test: yes - distro: Ubuntu version: 24.04 @@ -212,6 +218,15 @@ jobs: CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} + # Which build the suite runs in, decided once per leg here so the steps' if: conditions + # and their shells cannot drift apart. A step's own env is not visible to its if:, which + # is why these live at job level rather than on the release step. + # DEBUG_TEST the instrumented legs (symbols and instrumentation matter) and the two + # newest distro toolchains, which only fail on a warning in Debug. + # RUN_TESTS every other leg that tests runs the suite against the release build it may + # also package, so testing and packaging cost one compile instead of two. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} steps: - name: "Checkout" @@ -228,8 +243,11 @@ jobs: shell: bash run: source .ci/docker.sh --build + # The instrumented legs and the two newest distro toolchains build and test Debug; every + # other leg runs its suite against the release build below, so testing and packaging cost + # one compile instead of two. - name: "Build debug and test" - if: matrix.test != 'skip' + if: env.DEBUG_TEST == 'true' shell: bash env: sanitize: '${{ matrix.sanitize }}' @@ -269,15 +287,24 @@ jobs: path: coverage/ if-no-files-found: warn - - name: "Build release package" + # One build serves both purposes: the suite runs against the same configuration that + # gets packaged, so a packaging leg no longer pays for a full Debug compile it then + # throws away. macOS and Windows have always tested their release build. + # + # Instrumented and debug-test legs reach this step only when they package (the sanitizer + # leg currently does, the coverage leg does not); their suite already ran in the debug + # step above, so the tests are not repeated here. + - name: "Build release, test and package" id: build - if: matrix.package != 'skip' + if: matrix.package != 'skip' || env.RUN_TESTS == 'true' shell: bash env: SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}' package: '${{ matrix.package }}' server_only: '${{ matrix.server_only }}' - WITH_CRASH_REPORTING: 'ON' + # Only a leg that packages builds the shipping configuration; a release build that + # only runs tests stays free of the crash reporting SDK. + WITH_CRASH_REPORTING: ${{ matrix.package != 'skip' && 'ON' || '' }} # Tag builds only: artifacts from master pushes and PR branches must not report to # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN @@ -287,6 +314,7 @@ jobs: source .ci/docker.sh args=() [[ $server_only == yes ]] && args+=(--no-client) + [[ $RUN_TESTS == true ]] && args+=(--test) [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") args+=(--ccache "$CCACHE_SIZE") args+=(--cmake-generator "$CMAKE_GENERATOR") @@ -296,7 +324,14 @@ jobs: # configures, and empty keeps the mock fallback. export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN" - RUN --server --release --package "$package" "${args[@]}" + # --package takes an optional type, so the flag and its argument have to travel + # together or the type would become the next flag. + package_args=() + [[ -n $package && $package != skip ]] && package_args+=(--package "$package") + + # compile.sh runs the tests before the package target, so a failing suite stops the + # leg before anything is built into an installer. + RUN --server --release "${package_args[@]}" "${args[@]}" - name: "Install sentry-cli" if: matrix.package != 'skip' && needs.configure.outputs.tag != null From 3f7aa4ca8222d8bd813aaa5b30df1b2be2cba45f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:24:24 +0100 Subject: [PATCH 214/247] [CI] Split the sanitizer leg from the Ubuntu packaging leg (#7666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sanitize leg re-ran the Release packaging build after the instrumented one, so 18 minutes of Release compile queued behind the sanitizer run in a single job. Give sanitize its own leg with its own image and ccache, and keep the plain Ubuntu leg publishing the DEB. - The coverage leg's packaging comment now points at the leg that actually publishes the DEB. - The -DENABLE_SANITIZERS=OFF comment no longer describes a debug-sanitize then release-package sequence that no job runs. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 6 +++--- .github/workflows/desktop-build.yml | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 9be8ba741..1db0cacd1 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -175,9 +175,9 @@ fi if [[ $MAKE_SANITIZE ]]; then flags+=("-DENABLE_SANITIZERS=ON") else - # The release-package step re-runs cmake in the same build dir, so an - # ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak - # into the shipped binaries (libasan/libubsan Depends in the DEB). + # Every leg states the flag explicitly rather than relying on the CMake + # default, so a build directory reused from a sanitized configuration can + # never leak libasan/libubsan into a shipped binary. flags+=("-DENABLE_SANITIZERS=OFF") fi if [[ $MAKE_COVERAGE ]]; then diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 16610d872..9f2d679fa 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -187,10 +187,21 @@ jobs: version: 26.04 package: DEB + test: skip # The sanitizer leg below runs this distro's suite - # The one leg that runs the suite under AddressSanitizer and - # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the - # uninstrumented suite, so nothing is lost by instrumenting this one. + # The one leg that runs the suite under AddressSanitizer and + # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the + # uninstrumented suite, so nothing is lost by instrumenting this one. + # + # It packages nothing. A sanitized Debug build is not something to ship, and + # carrying the package build here meant 18 minutes of Release compile queued + # behind the instrumented one in a single job. The leg above publishes this + # distro's DEB. + - distro: Ubuntu + version: 26.04 + variant: sanitize # separate image and ccache, same distro + + package: skip sanitize: yes - distro: Ubuntu @@ -202,7 +213,7 @@ jobs: # sanitizer leg because the two instruments answer different questions: the # sanitizer leg reports what the run did wrong, the coverage leg reports what the # run never touched at all. Combining them would also slow both down. Packaging is - # skipped because the leg above already publishes the DEB for this distro. + # skipped because the plain Ubuntu 26.04 leg publishes the DEB for this distro. coverage: yes package: skip From 140fbb4484d2a4854a2d4927ca34590514a3cde1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:42:35 +0100 Subject: [PATCH 215/247] [CI] Cover the legacy distros and the slow macOS runner in a nightly (#7668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Cover the legacy distros and the slow macOS runner in a nightly Every pull request built ten Linux legs and five macOS/Windows legs, including the previous release of each distro and macOS 13 on the Intel runner. Those legs exist to catch a regression for users on an older distribution, which a change being reviewed almost never does, and macOS 13 Intel is the slowest leg of the matrix - 28 minutes against 19 for the rest. They now run in a nightly build of the default branch and on tag builds, which is when a change actually ships. Pull requests keep the current release of each distro, the sanitizer leg and the coverage leg. * [CI] Run the nightly legs from their own jobs jobs..if cannot read matrix, so the previous filter never validated and no leg of this workflow ran. matrix.exclude cannot express it either: GitHub processes exclude before include, and these matrices are include-only. Split the regular and nightly legs into two jobs per platform, sharing the step list through a YAML anchor and gating the jobs on the event instead of on matrix. The nightly run now covers only the legs a push leaves out, and the event name is part of the concurrency group so a nightly and a master push do not cancel each other. * [CI] Run the coverage leg in the nightly gcovr.cfg sets no fail_under, so the leg cannot fail a build, and nothing consumes coverage-report: it was an artifact produced on every pull request and read by no one. In the nightly it becomes a trend over the default branch instead, next to the legs a change being reviewed does not wait for. The leg itself is unchanged - same distro, version and variant, so the image name and ccache key still match the warm cache it built. It is marked allow-failure so a reporting leg cannot redden the nightly run that vcpkg-cache-import requires to conclude in success. The release-test comment loses its per-leg parenthetical: it claimed the sanitizer leg packages, which #7666 stopped being true, and the coverage leg is no longer in this job. --------- Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 178 +++++++++++++++-------- .github/workflows/vcpkg-cache-import.yml | 16 +- 2 files changed, 129 insertions(+), 65 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 9f2d679fa..d72cb881e 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -33,10 +33,18 @@ on: - 'CMakeLists.txt' - 'vcpkg.json' - 'vcpkg' # needed to match submodule bumps (gitlink) + # The legs the pull-request matrix leaves out run here instead, so the legacy distros and + # the slow macOS runner stay covered without gating a pull request on them. Tags still + # build every leg, because a tag is what ships. + schedule: + - cron: '23 3 * * *' # Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release) concurrency: - group: "${{ github.workflow }} @ ${{ github.ref_name }}" + # The event name is part of the group because a nightly run and a push run on master share + # refs/heads/master but must not cancel each other: the nightly would drop the push's vcpkg + # import, and a merge landing during the nightly would drop that night's coverage. + group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})" cancel-in-progress: ${{ github.ref_type != 'tag' }} jobs: @@ -150,23 +158,11 @@ jobs: server_only: yes test: skip - - distro: Debian - version: 12 - - package: DEB - test: skip # Running tests on all distros is superfluous - - distro: Debian version: 13 package: DEB - - distro: Fedora - version: 43 - - package: RPM - test: skip # Running tests on all distros is superfluous - - distro: Fedora version: 44 @@ -177,12 +173,6 @@ jobs: # new warning in either compiler still fails the build. debug_test: yes - - distro: Ubuntu - version: 24.04 - - package: DEB - test: skip # Running tests on all distros is superfluous - - distro: Ubuntu version: 26.04 @@ -204,21 +194,11 @@ jobs: package: skip sanitize: yes - - distro: Ubuntu - version: 26.04 - variant: cover # separate image and ccache, same distro - - # The one leg that runs instrumented for line coverage and publishes the report as - # a workflow artifact. It is a separate leg rather than a second flag on the - # sanitizer leg because the two instruments answer different questions: the - # sanitizer leg reports what the run did wrong, the coverage leg reports what the - # run never touched at all. Combining them would also slow both down. Packaging is - # skipped because the plain Ubuntu 26.04 leg publishes the DEB for this distro. - coverage: yes - package: skip - name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure + # The nightly legs live in build-linux-nightly below. A leg is in exactly one of the two + # matrices, so nothing that reviews a change waits for a distro nobody is changing for. + if: github.event_name != 'schedule' runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} timeout-minutes: 70 @@ -239,7 +219,8 @@ jobs: DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} - steps: + # Shared with build-linux-nightly so the two leg sets run the same steps. + steps: &linux-steps - name: "Checkout" uses: actions/checkout@v7 @@ -302,9 +283,8 @@ jobs: # gets packaged, so a packaging leg no longer pays for a full Debug compile it then # throws away. macOS and Windows have always tested their release build. # - # Instrumented and debug-test legs reach this step only when they package (the sanitizer - # leg currently does, the coverage leg does not); their suite already ran in the debug - # step above, so the tests are not repeated here. + # Instrumented and debug-test legs reach this step only when they also package; their + # suite already ran in the debug step above, so the tests are not repeated here. - name: "Build release, test and package" id: build if: matrix.package != 'skip' || env.RUN_TESTS == 'true' @@ -444,30 +424,72 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice + build-linux-nightly: + strategy: + fail-fast: false + matrix: + include: + - distro: Debian + version: 12 # previous stable, still covered by the nightly + + package: DEB + test: skip # Running tests on all distros is superfluous + + - distro: Fedora + version: 43 # previous release, still covered by the nightly + + package: RPM + test: skip # Running tests on all distros is superfluous + + - distro: Ubuntu + version: 24.04 # previous LTS, still covered by the nightly + + package: DEB + test: skip # Running tests on all distros is superfluous + + # The one leg that runs instrumented for line coverage and publishes the report as + # a workflow artifact. It stays a separate leg rather than a second flag on the + # sanitizer leg because the two instruments answer different questions: the + # sanitizer leg reports what the run did wrong, the coverage leg reports what the + # run never touched at all. Combining them would also slow both down. + # + # It moved here from build-linux: gcovr.cfg sets no fail_under, so the leg cannot + # fail a build, and nothing consumes the report per change, which made it an + # artifact nobody read on every pull request. On the default branch it becomes a + # trend instead. Image and ccache keys come from distro, version and variant, so + # they are unchanged and the warm cache that leg built still matches. + - distro: Ubuntu + version: 26.04 + variant: cover # separate image and ccache, same distro + + coverage: yes + package: skip + # A report must not redden the nightly run: vcpkg-cache-import only imports the + # archives of a run whose conclusion is success. + allow-failure: yes + + name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} + needs: configure + # A nightly and a tag build cover the legs a pull request or a merge does not wait for: + # the previous release of each distro, and the one instrument that only reports. + if: github.event_name == 'schedule' || github.ref_type == 'tag' + runs-on: ubuntu-latest # https://github.com/actions/runner-images + continue-on-error: ${{ matrix.allow-failure == 'yes' }} + timeout-minutes: 70 + env: + CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache + CCACHE_EVICTION_AGE: 7d + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CMAKE_GENERATOR: 'Ninja' + NAME: ${{ matrix.distro }}${{ matrix.version }} + IMAGE_SUFFIX: ${{ matrix.variant }} + steps: *linux-steps + build-vcpkg: strategy: fail-fast: false matrix: include: - - os: macOS - target: 13 # EOL 2025-09-15 - runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md - # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout - - ccache_eviction_age: 7d - cmake_generator: Ninja - make_package: 1 - override_target: 13 - package_suffix: "-macOS13_Intel" - qt_version: 6.11.* - qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets - soc: Intel - type: Release - use_ccache: 1 - vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' - vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over - xcode: "26.3" - - os: macOS target: 14 # EOL 2026-?? runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md @@ -543,13 +565,16 @@ jobs: name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} needs: [configure, vcpkg-baseline] + # The nightly leg lives in build-vcpkg-nightly below, next to the Linux nightly legs. + if: github.event_name != 'schedule' runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy - steps: + # Shared with build-vcpkg-nightly so the two leg sets run the same steps. + steps: &vcpkg-steps - name: "Checkout" uses: actions/checkout@v7 with: @@ -1024,6 +1049,42 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice + build-vcpkg-nightly: + strategy: + fail-fast: false + matrix: + include: + - os: macOS + target: 13 # EOL 2025-09-15 + runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout + + ccache_eviction_age: 7d + cmake_generator: Ninja + make_package: 1 + override_target: 13 + package_suffix: "-macOS13_Intel" + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets + soc: Intel + type: Release + use_ccache: 1 + vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' + vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over + xcode: "26.3" + + name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} + needs: configure + # The EOL target on the slowest runner is too slow to gate a change, so it runs in the + # nightly and on tags only, where the vcpkg cache import also has to accept schedule runs. + if: github.event_name == 'schedule' || github.ref_type == 'tag' + runs-on: ${{ matrix.runner }} + timeout-minutes: 100 + env: + CCACHE_DIR: ${{ github.workspace }}/.cache/ + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + steps: *vcpkg-steps + prune-caches: name: Prune caches # Only master writes caches at all, so only master may clean them up too @@ -1042,8 +1103,9 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - # Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of - # a scope that is still in use get deleted on the next run. + # Must list every vcpkg_scope of the build-vcpkg and build-vcpkg-nightly + # matrices above, or the entries of a scope that is still in use get deleted + # on the next run. LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml index 3cc7b2203..0a377a8fd 100644 --- a/.github/workflows/vcpkg-cache-import.yml +++ b/.github/workflows/vcpkg-cache-import.yml @@ -4,12 +4,14 @@ name: Import vcpkg cache # rebuilds protobuf, openssl and abseil from source and saves them into the shared # cache, where every later run can restore them. # -# Only master pushes are imported here: the collect step, keys.txt and the zip -# contents all come from the workflow run being imported, so importing from an -# unmerged pull request would let anyone who can push a branch — or a bot actor -# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into -# the scope that master and tag builds restore and link into signed releases. -# Forks build untrusted code and must never reach that scope either. +# Only master pushes and the nightly schedule run on master are imported here: the +# collect step, keys.txt and the zip contents all come from the workflow run being +# imported, so importing from an unmerged pull request would let anyone who can push +# a branch — or a bot actor such as Dependabot, whose PRs are same-repo — promote +# arbitrary binaries into the scope that master and tag builds restore and link into +# signed releases. Forks build untrusted code and must never reach that scope either. +# A scheduled run is the default branch at the commit a master push already built, so +# it carries the same trust as a push. # # repository_dispatch is not used on purpose: it would also resolve to the default branch, # but it counts as a low-trust trigger and may only write there when it declares a @@ -38,7 +40,7 @@ jobs: # the shared scope even if they somehow target the default branch. if: >- github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && + (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') && github.event.workflow_run.head_branch == 'master' && github.event.workflow_run.head_repository.full_name == github.repository && !endsWith(github.event.workflow_run.actor.login, '[bot]') && From d8700b29471cd061e5626af0e9befae25ad1ed53 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:52 +0100 Subject: [PATCH 216/247] [Fonts] Add AppFont roles and FontConfig fonts.toml round-trip (#7534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_config.cpp | 148 +++++++++++++++- cockatrice/src/interface/theme_config.h | 49 +++++- tests/CMakeLists.txt | 9 + tests/font_config_test.cpp | 195 ++++++++++++++++++++++ 4 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 tests/font_config_test.cpp diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 652e322c8..54143d7d3 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -304,4 +304,150 @@ QPalette PaletteConfig::apply(QPalette base) const } return base; -} \ No newline at end of file +} + +bool FontConfig::hasFonts() const +{ + return !fonts.isEmpty(); +} + +QString FontConfig::toToml() const +{ + QMetaEnum roleEnum = QMetaEnum::fromType(); + + QString entries; + + for (auto it = fonts.cbegin(); it != fonts.cend(); ++it) { + const char *roleName = roleEnum.valueToKey(it.key()); + + if (!roleName) { + continue; + } + + QString family = it.value().family().trimmed(); + int pointSize = it.value().pointSize(); + + if (family.isEmpty() || pointSize <= 0) { + continue; + } + + entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize); + } + + if (entries.isEmpty()) { + return {}; + } + + return "[Fonts]\n" + entries + "\n"; +} + +QString FontConfig::fileName() +{ + return "fonts.toml"; +} + +FontConfig FontConfig::fromFile(const QString &filePath) +{ + FontConfig cfg; + + QFile f(filePath); + + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return cfg; + } + + QMetaEnum roleEnum = QMetaEnum::fromType(); + + QString currentSection; + + QTextStream in(&f); + + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + + if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) { + continue; + } + + if (line.startsWith('[') && line.endsWith(']')) { + currentSection = line.mid(1, line.length() - 2).trimmed(); + continue; + } + + if (currentSection.compare("Fonts", Qt::CaseInsensitive) != 0) { + continue; + } + + int eq = line.indexOf('='); + + if (eq < 0) { + continue; + } + + QString key = line.left(eq).trimmed(); + QString value = line.mid(eq + 1).trimmed(); + + // Strip inline comments if preceded by whitespace + for (int i = 1; i < value.size(); ++i) { + if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) { + value = value.left(i).trimmed(); + break; + } + } + + // Strip surrounding quotes, if any + if (value.size() >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) { + value = value.mid(1, value.size() - 2).trimmed(); + } + + int comma = value.lastIndexOf(','); + + if (comma < 0) { + continue; + } + + QString family = value.left(comma).trimmed(); + + // Tolerate a partially-quoted family, e.g. "Inter", 10 + if (family.size() >= 2 && + ((family.startsWith('"') && family.endsWith('"')) || (family.startsWith('\'') && family.endsWith('\'')))) { + family = family.mid(1, family.size() - 2).trimmed(); + } + + bool ok = false; + int pointSize = value.mid(comma + 1).trimmed().toInt(&ok); + + if (!ok || family.isEmpty() || pointSize <= 0) { + continue; + } + + if (key.startsWith("AppFont::")) { + key = key.mid(QStringLiteral("AppFont::").size()); + } + + int roleInt = roleEnum.keyToValue(key.toUtf8().constData()); + + if (roleInt < 0) { + continue; + } + + // Canonical family + point size only: a fresh QFont carries default + // (non-bold, non-italic) weight/style, so nothing caller-side leaks in. + // No validation against installed fonts; Qt substitution applies. + QFont font(family); + font.setPointSize(pointSize); + cfg.fonts[static_cast(roleInt)] = font; + } + + return cfg; +} + +FontConfig FontConfig::fromThemeDir(const QString &themeDirPath) +{ + if (themeDirPath.isEmpty()) { + return {}; + } + + return fromFile(QDir(themeDirPath).absoluteFilePath(fileName())); +} diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 8b91613f5..84601a2bf 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -2,6 +2,7 @@ #define COCKATRICE_THEME_CONFIG_H #include +#include #include #include #include @@ -23,6 +24,23 @@ enum Role Q_ENUM_NS(Role) } // namespace AppColor +// Application-specific font roles, one configurable family + point size per +// purpose. Stored in a shared fonts.toml under a [Fonts] section (one file per +// theme, not split by light/dark). Only family and point size are stored; +// weight/italic are stripped on load and stay caller-side concerns. +namespace AppFont +{ +Q_NAMESPACE +enum Role +{ + InterfaceDefault, + ChatLog, + CardText, + DeckList, +}; +Q_ENUM_NS(Role) +} // namespace AppFont + struct ThemeConfig { QString colorScheme; @@ -60,4 +78,33 @@ struct PaletteConfig QPalette apply(QPalette base) const; }; -#endif // COCKATRICE_THEME_CONFIG_H \ No newline at end of file +/** @brief Per-purpose font configuration, stored as family + point size only. + * + * Serialized to a shared fonts.toml file with a [Fonts] section, e.g. + * @code + * [Fonts] + * InterfaceDefault = "Inter, 10" + * @endcode + */ +struct FontConfig +{ + QMap fonts; + + bool operator==(const FontConfig &rhs) const + { + return fonts == rhs.fonts; + } + bool operator!=(const FontConfig &rhs) const + { + return !(*this == rhs); + } + bool hasFonts() const; + QString toToml() const; + + static QString fileName(); + + static FontConfig fromFile(const QString &filePath); + static FontConfig fromThemeDir(const QString &themeDirPath); +}; + +#endif // COCKATRICE_THEME_CONFIG_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4d7a02ea4..c87bec627 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -424,6 +424,15 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) +cockatrice_add_test( + NAME + font_config_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp + font_config_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME deck_hash_performance_test diff --git a/tests/font_config_test.cpp b/tests/font_config_test.cpp new file mode 100644 index 000000000..c8b1fb10d --- /dev/null +++ b/tests/font_config_test.cpp @@ -0,0 +1,195 @@ +#include "interface/theme_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Writes content to a file inside tmp, creating parents as needed. +QString writeTempFile(QTemporaryDir &tmp, const QString &relativePath, const QString &content) +{ + const QString filePath = QDir(tmp.path()).absoluteFilePath(relativePath); + EXPECT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath())); + QFile file(filePath); + EXPECT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << content; + file.close(); + return filePath; +} + +QFont makeFont(const QString &family, int pointSize) +{ + QFont font(family); + font.setPointSize(pointSize); + return font; +} +} // namespace + +TEST(FontConfigTest, FileNameIsFontsToml) +{ + EXPECT_EQ(FontConfig::fileName(), QStringLiteral("fonts.toml")); +} + +TEST(FontConfigTest, EmptyByDefault) +{ + FontConfig cfg; + EXPECT_FALSE(cfg.hasFonts()); + EXPECT_TRUE(cfg.toToml().isEmpty()); + EXPECT_EQ(cfg, FontConfig()); + EXPECT_EQ(cfg, FontConfig::fromFile("/nonexistent/path/fonts.toml")); + EXPECT_EQ(cfg, FontConfig::fromThemeDir(QString())); +} + +TEST(FontConfigTest, RoundTripPreservesFamilyAndSize) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + FontConfig cfg; + cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + cfg.fonts[AppFont::ChatLog] = makeFont("JetBrains Mono", 9); + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11); + cfg.fonts[AppFont::DeckList] = makeFont("Roboto", 12); + ASSERT_TRUE(cfg.hasFonts()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + EXPECT_EQ(loaded, cfg); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].family(), QStringLiteral("Inter")); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].pointSize(), 10); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("JetBrains Mono")); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 9); + EXPECT_EQ(loaded.fonts[AppFont::CardText].family(), QStringLiteral("Noto Serif")); + EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 11); + EXPECT_EQ(loaded.fonts[AppFont::DeckList].family(), QStringLiteral("Roboto")); + EXPECT_EQ(loaded.fonts[AppFont::DeckList].pointSize(), 12); +} + +TEST(FontConfigTest, RoundTripStripsWeightAndItalic) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + QFont styled("Inter"); + styled.setPointSize(10); + styled.setBold(true); + styled.setItalic(true); + + FontConfig cfg; + cfg.fonts[AppFont::ChatLog] = styled; + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + ASSERT_TRUE(loaded.fonts.contains(AppFont::ChatLog)); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("Inter")); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 10); + EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].bold()); + EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].italic()); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("Inter", 10)); +} + +TEST(FontConfigTest, ToTomlFormat) +{ + FontConfig cfg; + cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + + const QString toml = cfg.toToml(); + EXPECT_TRUE(toml.contains(QStringLiteral("[Fonts]"))); + EXPECT_TRUE(toml.contains(QStringLiteral("InterfaceDefault"))); + EXPECT_TRUE(toml.contains(QStringLiteral("\"Inter, 10\""))); +} + +TEST(FontConfigTest, MalformedLinesAreSkipped) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", + "# a comment\n" + "; another comment\n" + "\n" + "StrayBeforeSection = \"Inter, 10\"\n" + "\n" + "[Other]\n" + "ChatLog = \"ShouldBeIgnored, 10\"\n" + "\n" + "[Fonts]\n" + "InterfaceDefault = \"Inter, 10\"\n" + "ChatLog = \"JetBrains Mono\", 9 # inline comment\n" + "CardText = Noto Serif, 11\n" + "AppFont::DeckList = \"Roboto, 12\"\n" + "UnknownRole = \"Inter, 10\"\n" + "QPalette::Window = \"Inter, 10\"\n" + "NoComma = \"Inter\"\n" + "EmptyFamily = \", 10\"\n" + "ZeroSize = \"Inter, 0\"\n" + "NegativeSize = \"Inter, -3\"\n" + "NotANumber = \"Inter, big\"\n" + "NotAnEntry\n"); + + const FontConfig loaded = FontConfig::fromFile(filePath); + + ASSERT_TRUE(loaded.hasFonts()); + EXPECT_EQ(loaded.fonts.size(), 4); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault], makeFont("Inter", 10)); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("JetBrains Mono", 9)); + EXPECT_EQ(loaded.fonts[AppFont::CardText], makeFont("Noto Serif", 11)); + EXPECT_EQ(loaded.fonts[AppFont::DeckList], makeFont("Roboto", 12)); +} + +TEST(FontConfigTest, FromThemeDirReadsFontsToml) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QString themeDir = QDir(tmp.path()).absoluteFilePath("MyTheme"); + ASSERT_TRUE(QDir().mkpath(themeDir)); + + FontConfig cfg; + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11); + writeTempFile(tmp, "MyTheme/fonts.toml", cfg.toToml()); + + const FontConfig loaded = FontConfig::fromThemeDir(themeDir); + EXPECT_EQ(loaded, cfg); + + // A theme directory without fonts.toml loads as empty. + const QString bareDir = QDir(tmp.path()).absoluteFilePath("BareTheme"); + ASSERT_TRUE(QDir().mkpath(bareDir)); + EXPECT_FALSE(FontConfig::fromThemeDir(bareDir).hasFonts()); +} + +TEST(FontConfigTest, InequalityComparesRoles) +{ + FontConfig left; + left.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + + FontConfig right = left; + EXPECT_EQ(left, right); + EXPECT_FALSE(left != right); + + right.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 11); + EXPECT_NE(left, right); + + right = left; + right.fonts[AppFont::ChatLog] = makeFont("Inter", 10); + EXPECT_NE(left, right); +} + +// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and +// the fallback then links the bare gtest library without gtest_main. +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 54797452daf84604444f202edcf3f52072cdcd66 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:52 +0100 Subject: [PATCH 217/247] [Fonts] Wire FontConfig into ThemeManager with appFont and preview (#7535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Wire FontConfig into ThemeManager with appFont and preview * [Fonts] Restore the startup font when a config has no InterfaceDefault - Capture the untouched application font next to the default palette and use it in previewFonts' else branch, so a config without InterfaceDefault restores the startup font instead of leaving the last preview applied. - Drop the unpolish/polish loop from previewFonts: QApplication::setFont already notifies every widget, and the loop ran on every editor auto-apply. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_manager.cpp | 80 +++++++++++++++++++++- cockatrice/src/interface/theme_manager.h | 14 ++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 893cdc795..b23f15a2b 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -127,8 +127,10 @@ static QString usableDefaultStyle(const QString &style) ThemeManager::ThemeManager(QObject *parent) : QObject(parent) { defaultStyleName = usableDefaultStyle(qApp->style()->objectName()); - // Capture the untouched application palette before any theme is applied. + // Capture the untouched application palette and font before any theme is + // applied. defaultPalette = qApp->palette(); + defaultFont = qApp->font(); ensureThemeDirectoryExists(); #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { @@ -440,6 +442,34 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath return cfg; } +FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath) +{ + if (themeDirPath.isEmpty()) { + return {}; + } + return FontConfig::fromThemeDir(themeDirPath); +} + +bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg) +{ + if (themeDirPath.isEmpty()) { + return false; + } + + QDir dir(themeDirPath); + if (!dir.exists()) { + dir.mkpath("."); + } + + QFile f(dir.absoluteFilePath(FontConfig::fileName())); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + return false; + } + + QTextStream(&f) << cfg.toToml(); + return true; +} + bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) { if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { @@ -488,6 +518,24 @@ void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &schem applyStyleAndPalette(themeName, themeCfg, cfg, scheme); } +void ThemeManager::previewFonts(const FontConfig &cfg) +{ + currentFonts = cfg; + + // QApplication::setFont already sends a font change to every widget, so a + // repolish pass is not needed for fonts (unlike the palette path). + const auto it = cfg.fonts.constFind(AppFont::InterfaceDefault); + if (it != cfg.fonts.constEnd()) { + qApp->setFont(it.value()); + } else { + // A config without InterfaceDefault means "no override": restore the + // startup font instead of leaving the last preview applied. + qApp->setFont(defaultFont); + } + + emit fontsChanged(); +} + void ThemeManager::applyStyleAndPalette(const QString &themeName, const ThemeConfig &themeCfg, const PaletteConfig &palCfg, @@ -591,6 +639,18 @@ QColor ThemeManager::appColor(AppColor::Role role) const return accent; } +QFont ThemeManager::appFont(AppFont::Role role) const +{ + const auto it = currentFonts.fonts.constFind(role); + if (it != currentFonts.fonts.constEnd()) { + return it.value(); + } + + // No per-role scaling: every unset role resolves to the application font, + // so with no fonts.toml present the result is identical to before. + return qApp->font(); +} + void ThemeManager::themeChangedSlot() { QString themeName = SettingsCache::instance().getThemeName(); @@ -642,6 +702,24 @@ void ThemeManager::themeChangedSlot() applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); + // ── Load fonts: shared per theme, no light/dark split ─────────────────── + FontConfig fonts = loadFontConfig(dirPath); + if (!fonts.hasFonts()) { + // The shipped fonts.toml may live in the system theme directory rather + // than the resolved (user) theme directory, so built-in themes still + // get their curated fonts. + const QString sysFontPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); + if (sysFontPath != dirPath) { + fonts = loadFontConfig(sysFontPath); + } + } + currentFonts = fonts; + const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault); + if (fontIt != currentFonts.fonts.constEnd()) { + qApp->setFont(fontIt.value()); + } + emit fontsChanged(); + QStringList resources; if (!dirPath.isEmpty()) { resources << dir.absolutePath(); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index c1ab65685..c8fba2eb4 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -47,10 +48,15 @@ private: // palette is applied. Used as the base when a theme supplies no palette, so // switching away from a custom palette restores the original colours. QPalette defaultPalette; + // Pristine application font captured at startup, before any theme font is + // applied. Restored when a theme (or a preview) supplies no InterfaceDefault + // font, so switching away from a themed font cannot leave it behind. + QFont defaultFont; QString currentThemePath; std::array brushes; QStringMap availableThemes; QMap currentAppColors; + FontConfig currentFonts; /* Internal cache for multiple backgrounds */ @@ -105,6 +111,9 @@ public: // Load/save per-scheme palette colors static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); + // Load/save shared per-theme fonts (fonts.toml, not split by scheme) + static FontConfig loadFontConfig(const QString &themeDirPath); + static bool saveFontConfig(const QString &themeDirPath, const FontConfig &cfg); // Resolve prefix to a scheme-qualified "theme:" path. Existence is probed // internally across the formats themes may ship (.png/.jpg/.svg), so // callers load the returned path directly. Prefers "-" @@ -132,10 +141,14 @@ public: void reloadCurrentTheme(); void previewPalette(const PaletteConfig &cfg, const QString &scheme); + void previewFonts(const FontConfig &cfg); // Resolves an application color role: the theme's stored [AppColors] value // when present, otherwise a palette-accent-derived fallback. QColor appColor(AppColor::Role role) const; + /** @brief Resolves an application font role: the theme's stored [Fonts] + * value when present, otherwise the application font. */ + QFont appFont(AppFont::Role role) const; QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); @@ -144,6 +157,7 @@ protected slots: signals: void themeChanged(); void paletteChanged(); + void fontsChanged(); }; extern ThemeManager *themeManager; From 634f8115a52d09d4d13f1e60800edffcc0fb962b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:53 +0100 Subject: [PATCH 218/247] [Fonts] Add per-purpose font section to palette editor (#7536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Add per-purpose font section to palette editor * [Fonts] Make the editor's fallback and Reset preview-independent - Add ThemeManager::defaultAppFont(), which resolves an unset role against the startup font instead of currentFonts, and use it in FontGridWidget::loadFonts so the grid never shows the live preview as the theme's default. - Reload the live theme in PaletteEditorDialog::onReset before re-reading the grid, so Reset restores the saved fonts before loadFonts resolves fallbacks. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../palette_editor/font_grid_widget.cpp | 127 ++++++++++++++++++ .../palette_editor/font_grid_widget.h | 43 ++++++ .../palette_editor/palette_editor_dialog.cpp | 112 +++++++++++++-- .../palette_editor/palette_editor_dialog.h | 12 ++ cockatrice/src/interface/theme_manager.cpp | 24 ++++ cockatrice/src/interface/theme_manager.h | 8 ++ 7 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 cockatrice/src/interface/palette_editor/font_grid_widget.cpp create mode 100644 cockatrice/src/interface/palette_editor/font_grid_widget.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9b467721d..9a3172aa8 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -154,6 +154,7 @@ set(cockatrice_SOURCES src/interface/palette_editor/palette_generator.cpp src/interface/palette_editor/quick_setup_panel.cpp src/interface/palette_editor/palette_grid_widget.cpp + src/interface/palette_editor/font_grid_widget.cpp src/interface/palette_editor/palette_editor_dialog.cpp src/interface/widgets/cards/additional_info/color_identity_widget.cpp src/interface/widgets/cards/additional_info/deck_color_identity.cpp diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp new file mode 100644 index 000000000..0f01fbe09 --- /dev/null +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -0,0 +1,127 @@ +#include "font_grid_widget.h" + +#include "../theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int MIN_FONT_SIZE = 6; +static constexpr int MAX_FONT_SIZE = 72; +static constexpr int FALLBACK_FONT_SIZE = 10; + +static const QMap APP_FONT_DESCRIPTIONS = { + {AppFont::InterfaceDefault, QT_TR_NOOP("Default font for menus, dialogs and widgets")}, + {AppFont::ChatLog, QT_TR_NOOP("Font for chat messages and logs")}, + {AppFont::CardText, QT_TR_NOOP("Font for text rendered on cards")}, + {AppFont::DeckList, QT_TR_NOOP("Font for deck lists and the deck editor")}, +}; + +static QString roleDisplayName(AppFont::Role role) +{ + switch (role) { + case AppFont::InterfaceDefault: + return FontGridWidget::tr("Interface default"); + case AppFont::ChatLog: + return FontGridWidget::tr("Chat log"); + case AppFont::CardText: + return FontGridWidget::tr("Card text"); + case AppFont::DeckList: + return FontGridWidget::tr("Deck list"); + default: + return QString(); + } +} + +FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) +{ + auto *grid = new QGridLayout(this); + grid->setSpacing(3); + grid->setContentsMargins(12, 8, 12, 8); + grid->setColumnStretch(0, 0); + grid->setColumnStretch(1, 1); + grid->setColumnStretch(2, 0); + + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + + auto *label = new QLabel(this); + grid->addWidget(label, i, 0); + + auto *familyCombo = new QFontComboBox(this); + grid->addWidget(familyCombo, i, 1); + connect(familyCombo, &QFontComboBox::currentFontChanged, this, [this] { emit fontsChanged(); }); + + auto *sizeSpin = new QSpinBox(this); + sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE); + grid->addWidget(sizeSpin, i, 2); + connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); + + fontRows[role] = {label, familyCombo, sizeSpin}; + } + + retranslateUi(); +} + +void FontGridWidget::loadFonts(const FontConfig &cfg) +{ + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows[role]; + + // Roles absent from the file resolve the same way the runtime does: an unset + // role falls back to the config's InterfaceDefault, and only then to the + // startup font. appFont() would return the live preview instead, so Reset and + // a first load must not measure against it. + const QFont fallback = cfg.fonts.value(AppFont::InterfaceDefault, themeManager->defaultAppFont(role)); + const QFont font = cfg.fonts.value(role, fallback); + row.familyCombo->setCurrentFont(font); + + int size = font.pointSize(); + if (size <= 0) { + size = qApp->font().pointSize(); + } + if (size <= 0) { + size = FALLBACK_FONT_SIZE; + } + row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE)); + } +} + +FontConfig FontGridWidget::currentFontConfig() const +{ + FontConfig cfg; + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows.value(role); + + // Canonical family + point size only: a fresh QFont carries default + // (non-bold, non-italic) weight/style, so nothing combo-side leaks in. + QFont font(row.familyCombo->currentFont().family()); + font.setPointSize(row.sizeSpin->value()); + cfg.fonts[role] = font; + } + return cfg; +} + +void FontGridWidget::retranslateUi() +{ + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows.value(role); + + row.label->setText(roleDisplayName(role)); + row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); + row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); + row.sizeSpin->setToolTip(tr("Point size")); + } +} diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.h b/cockatrice/src/interface/palette_editor/font_grid_widget.h new file mode 100644 index 000000000..8c80306d3 --- /dev/null +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_FONT_GRID_WIDGET_H +#define COCKATRICE_FONT_GRID_WIDGET_H + +#include "../theme_config.h" + +#include +#include +#include + +class QFontComboBox; +class QGridLayout; +class QLabel; +class QSpinBox; + +// Per-role font editors — one (family, point size) row per AppFont::Role. +// Owns the load/read round-trip for FontConfig but has no file I/O, mirroring +// PaletteGridWidget. Only family and point size are edited; weight and italic +// stay caller-side concerns and are stripped when reading back. +class FontGridWidget : public QWidget +{ + Q_OBJECT +public: + explicit FontGridWidget(QWidget *parent = nullptr); + + void loadFonts(const FontConfig &cfg); + FontConfig currentFontConfig() const; + void retranslateUi(); + +signals: + void fontsChanged(); + +private: + struct FontRow + { + QLabel *label = nullptr; + QFontComboBox *familyCombo = nullptr; + QSpinBox *sizeSpin = nullptr; + }; + + QMap fontRows; +}; + +#endif // COCKATRICE_FONT_GRID_WIDGET_H diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index b6271291d..09c81df5d 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -2,6 +2,7 @@ #include "../theme_config.h" #include "../theme_manager.h" +#include "font_grid_widget.h" #include "palette_generator.h" #include "palette_grid_widget.h" #include "quick_setup_panel.h" @@ -41,6 +42,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt // Load both scheme configs upfront so switching is instant loadSchemes(); + loadFonts(); loadedScheme = themeManager->isDarkMode(themeDirPath) ? "Dark" : "Light"; @@ -125,6 +127,32 @@ void PaletteEditorDialog::setupUi() paletteGrid->setVisible(false); root->addWidget(paletteGrid, 1); + // Font section — sibling to the palette grid, sharing its collapsible + // advanced area pattern. Fonts are shared per theme, not split by scheme. + fontToggleButton = new QPushButton(this); + fontToggleButton->setCheckable(true); + fontToggleButton->setChecked(false); + fontToggleButton->setFlat(true); + fontToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }" + "QPushButton:checked { }"); + root->addWidget(fontToggleButton); + + fontSeparator = makeSeparator(); + fontSeparator->setVisible(false); + root->addWidget(fontSeparator); + + fontSection = new QWidget; + auto *fontSectionLayout = new QVBoxLayout(fontSection); + fontSectionLayout->setContentsMargins(0, 0, 0, 0); + fontSectionLayout->setSpacing(0); + fontNoteLabel = new QLabel(fontSection); + fontNoteLabel->setContentsMargins(12, 4, 12, 0); + fontSectionLayout->addWidget(fontNoteLabel); + fontGrid = new FontGridWidget(fontSection); + fontSectionLayout->addWidget(fontGrid); + fontSection->setVisible(false); + root->addWidget(fontSection); + // Footer root->addWidget(makeSeparator()); footer = new QWidget; @@ -157,6 +185,7 @@ void PaletteEditorDialog::setupUi() connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply); connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent); connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); }); + connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { autoApplyTimer->start(); }); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); @@ -166,15 +195,32 @@ void PaletteEditorDialog::setupUi() paletteGridToggleButton->setText(open ? tr("▼ Edit Palette") : tr("▶ Edit Palette")); paletteGridSeparator->setVisible(open); paletteGrid->setVisible(open); - - if (open) { - setMinimumHeight(680); - resize(width(), 680); - } else { - setMinimumHeight(220); - adjustSize(); // shrinks to fit just the visible content - } + updateAdvancedHeight(); }); + connect(fontToggleButton, &QPushButton::toggled, this, [this](bool open) { + fontToggleButton->setText(open ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); + fontSeparator->setVisible(open); + fontSection->setVisible(open); + updateAdvancedHeight(); + }); +} + +void PaletteEditorDialog::updateAdvancedHeight() +{ + int height = 220; + if (paletteGridToggleButton->isChecked()) { + height += 460; + } + if (fontToggleButton->isChecked()) { + height += 200; + } + setMinimumHeight(height); + + if (paletteGrid->isVisible() || fontSection->isVisible()) { + resize(width(), height); + } else { + adjustSize(); // shrinks to fit just the visible content + } } void PaletteEditorDialog::retranslateUi() @@ -189,12 +235,16 @@ void PaletteEditorDialog::retranslateUi() editingLabel->setText(tr("Editing:")); paletteGridToggleButton->setText(tr("▶ Edit Palette")); paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks")); + fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); + fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme")); + fontNoteLabel->setText(tr("Fonts belong to theme %1").arg(themeName)); + fontGrid->retranslateUi(); revertButton->setText(tr("↺ Revert to theme default")); resetBtn->setText(tr("Reset")); saveBtn->setText(tr("Save")); - resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); - saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); + resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette and fonts")); + saveBtn->setToolTip(tr("Write palette-%1.toml and fonts.toml, then reload the theme").arg(loadedScheme.toLower())); if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); @@ -228,6 +278,19 @@ void PaletteEditorDialog::loadSchemes() } } +void PaletteEditorDialog::loadFonts() +{ + // Same user-dir > system-dir fallback ThemeManager uses: the shipped + // fonts.toml may live in the system theme directory rather than the + // resolved (user) theme directory. + FontConfig cfg = ThemeManager::loadFontConfig(themeDirPath); + if (!cfg.hasFonts()) { + cfg = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + } + savedFontConfig = cfg; + fontGrid->loadFonts(cfg); +} + void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme) { QColor seed = workingConfig.value(scheme).colors.value(QPalette::Active).value(QPalette::Highlight); @@ -258,6 +321,7 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi void PaletteEditorDialog::onApply() { themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme); + themeManager->previewFonts(fontGrid->currentFontConfig()); } void PaletteEditorDialog::onSave() @@ -308,6 +372,17 @@ void PaletteEditorDialog::onSave() savedConfig[it.key()] = it.value(); } + // Persist the shared per-theme fonts when they changed. + const FontConfig fontCfg = fontGrid->currentFontConfig(); + if (fontCfg != savedFontConfig) { + if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir)); + return; + } + savedFontConfig = fontCfg; + } + themeManager->reloadCurrentTheme(); accept(); } @@ -316,6 +391,11 @@ void PaletteEditorDialog::onReset() { workingConfig[loadedScheme] = savedConfig[loadedScheme]; paletteGrid->loadPalette(savedConfig[loadedScheme]); + // Restore the live theme before re-reading the grid: loadFonts resolves + // unset roles, and the preview has currentFonts pointing at the edited + // config until the theme is reloaded. + themeManager->reloadCurrentTheme(); + fontGrid->loadFonts(savedFontConfig); seedAccentFromScheme(loadedScheme); } @@ -350,6 +430,18 @@ void PaletteEditorDialog::onRevertToDefault() workingConfig[loadedScheme] = def; paletteGrid->loadPalette(def); seedAccentFromScheme(loadedScheme); + + // Revert the shared per-theme fonts the same way: delete the user-written + // fonts.toml so the theme falls back to its shipped default, then reflect + // the resolved fonts in the editor. + QFile::remove(QDir(saveDir).absoluteFilePath(FontConfig::fileName())); + + // Reload the live theme so the font revert takes effect immediately. + themeManager->reloadCurrentTheme(); + + const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + savedFontConfig = fontDef; + fontGrid->loadFonts(fontDef); } void PaletteEditorDialog::changeEvent(QEvent *e) diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index e29a70cb0..0a9d47c6c 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -15,6 +15,7 @@ class QComboBox; class QDialogButtonBox; class QPushButton; class PaletteGridWidget; +class FontGridWidget; class QuickSetupPanel; class QFrame; class QWidget; @@ -38,8 +39,10 @@ private: void setupUi(); void retranslateUi(); void refreshChromePalettes(); + void updateAdvancedHeight(); void loadScheme(const QString &scheme); // snapshot current, switch, load void seedAccentFromScheme(const QString &scheme); + void loadFonts(); // shared per theme, not split by light/dark // Sub-widgets QWidget *header; @@ -49,6 +52,11 @@ private: PaletteGridWidget *paletteGrid = nullptr; QPushButton *paletteGridToggleButton = nullptr; QFrame *paletteGridSeparator = nullptr; + FontGridWidget *fontGrid = nullptr; + QPushButton *fontToggleButton = nullptr; + QFrame *fontSeparator = nullptr; + QWidget *fontSection = nullptr; + QLabel *fontNoteLabel = nullptr; QWidget *footer; QComboBox *schemeComboBox = nullptr; QDialogButtonBox *buttonBox = nullptr; @@ -68,6 +76,10 @@ private: QMap workingConfig; QMap savedConfig; + // Fonts are shared per theme (fonts.toml, not split by light/dark), so a + // single saved config suffices; the live edits are read from fontGrid. + FontConfig savedFontConfig; + QTimer *autoApplyTimer = nullptr; protected: diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index b23f15a2b..bf275c7ac 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -450,6 +450,18 @@ FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath) return FontConfig::fromThemeDir(themeDirPath); } +FontConfig ThemeManager::loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName) +{ + FontConfig cfg = FontConfig::fromThemeDir(themeDirPath); + if (!cfg.hasFonts()) { + // The shipped fonts.toml may live in the system theme directory rather + // than the resolved (user) theme directory, so built-in themes still + // get their curated fonts. + cfg = FontConfig::fromThemeDir(QDir(systemThemesBasePath()).absoluteFilePath(themeName)); + } + return cfg; +} + bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg) { if (themeDirPath.isEmpty()) { @@ -651,6 +663,14 @@ QFont ThemeManager::appFont(AppFont::Role role) const return qApp->font(); } +QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const +{ + // No per-role scaling and no preview: an unset role resolves to the font + // captured at startup, which is what the theme falls back to once the + // editor closes. + return defaultFont; +} + void ThemeManager::themeChangedSlot() { QString themeName = SettingsCache::instance().getThemeName(); @@ -717,6 +737,10 @@ void ThemeManager::themeChangedSlot() const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault); if (fontIt != currentFonts.fonts.constEnd()) { qApp->setFont(fontIt.value()); + } else { + // No theme font: restore the pristine application font captured at + // startup, so a reverted preview cannot leak into the live theme. + qApp->setFont(defaultFont); } emit fontsChanged(); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index c8fba2eb4..37a70f755 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -132,6 +132,9 @@ public: // theme directory when it is absent from the resolved (user) directory. static PaletteConfig loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme); + // Load the theme's shipped default fonts, falling back to the system theme + // directory when fonts.toml is absent from the resolved (user) directory. + static FontConfig loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName); /** @brief Writes cfg to disk as the theme's palette-.toml and updates the * theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave * and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */ @@ -149,6 +152,11 @@ public: /** @brief Resolves an application font role: the theme's stored [Fonts] * value when present, otherwise the application font. */ QFont appFont(AppFont::Role role) const; + /** @brief Resolves an application font role against the startup font only, + * ignoring any previewed config in currentFonts. Editors that show + * "what this role resolves to" must use this, because appFont() + * reflects a live preview while the editor is open. */ + QFont defaultAppFont(AppFont::Role role) const; QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); From 7a40ed9079f14ed1b91769deff6633a5e183fabe Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:53 +0100 Subject: [PATCH 219/247] [Fonts] Migrate ChatLog surfaces to theme font role (#7537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Migrate ChatLog surfaces to theme font role * [Fonts] Keep ChatView on the app font and share the report log font - ChatLog's unset fallback is qApp->font() again: ChatView never set a font before the role existed, so an unset role must not switch chat and the game log to monospace at the system GeneralFont size. - Move the monospace fallback the report dialogs used before into report_utils::reportLogFont(), which returns the theme's ChatLog role when it is set and the old monospace otherwise. tab_report, dlg_my_reports and dlg_report_user now share it, so they cannot drift and they honour a themed size instead of overriding it. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_manager.cpp | 18 ++++++++++++++++-- cockatrice/src/interface/theme_manager.h | 5 ++++- .../widgets/dialogs/dlg_my_reports.cpp | 11 +++++++++-- .../interface/widgets/dialogs/dlg_my_reports.h | 1 + .../widgets/dialogs/dlg_report_user.cpp | 11 ++++++++++- .../widgets/dialogs/dlg_report_user.h | 1 + .../widgets/server/chat_view/chat_view.cpp | 14 ++++++++++++++ .../widgets/server/chat_view/chat_view.h | 1 + .../src/interface/widgets/tabs/tab_report.cpp | 10 +++++++++- .../src/interface/widgets/tabs/tab_report.h | 1 + .../interface/widgets/utility/report_utils.cpp | 16 ++++++++++++++++ .../interface/widgets/utility/report_utils.h | 7 +++++++ 12 files changed, 89 insertions(+), 7 deletions(-) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index bf275c7ac..8d538c134 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -658,11 +659,24 @@ QFont ThemeManager::appFont(AppFont::Role role) const return it.value(); } - // No per-role scaling: every unset role resolves to the application font, - // so with no fonts.toml present the result is identical to before. + // Role fallbacks preserve the pre-theme defaults when no fonts.toml is + // present: card text starts from a serif basis, everything else from the + // application font. The report dialogs that used to hardcode monospace ask + // for it explicitly (report_utils::reportLogFont). + if (role == AppFont::CardText) { + return QFont(QStringLiteral("Serif")); + } + + // No per-role scaling: every other unset role resolves to the application + // font, so with no fonts.toml present the result is identical to before. return qApp->font(); } +bool ThemeManager::hasAppFont(AppFont::Role role) const +{ + return currentFonts.fonts.contains(role); +} + QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const { // No per-role scaling and no preview: an unset role resolves to the font diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 37a70f755..aba91faf7 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -150,8 +150,11 @@ public: // when present, otherwise a palette-accent-derived fallback. QColor appColor(AppColor::Role role) const; /** @brief Resolves an application font role: the theme's stored [Fonts] - * value when present, otherwise the application font. */ + * value when present, otherwise a per-role fallback (serif basis + * for CardText, the application font for the remaining roles). */ QFont appFont(AppFont::Role role) const; + /** @brief Whether the active theme defines this role in fonts.toml. */ + bool hasAppFont(AppFont::Role role) const; /** @brief Resolves an application font role against the startup font only, * ignoring any previewed config in currentFonts. Editors that show * "what this role resolves to" must use this, because appFont() diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 2a2758ba5..a7387a068 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -1,11 +1,13 @@ #include "dlg_my_reports.h" +#include "../interface/theme_manager.h" #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include +#include #include #include #include @@ -24,7 +26,6 @@ #include #include #include -#include #include #include @@ -69,7 +70,8 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) auto *chatGroup = new QGroupBox(tr("Chat Log Context")); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &DlgMyReports::applyChatLogFont); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); @@ -122,6 +124,11 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) refreshList(); } +void DlgMyReports::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void DlgMyReports::refreshList() { selectedReportIdBeforeRefresh = selectedReportId; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h index dc9f90d2d..90772f051 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h @@ -29,6 +29,7 @@ private slots: void reportDetailsResponse(const Response &response); void addComment(); void addCommentResponse(const Response &response); + void applyChatLogFont(); private: void loadReportDetails(const ServerInfo_Report &report); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index 3ef45020b..c7c32469b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -1,10 +1,13 @@ #include "dlg_report_user.h" +#include "../interface/theme_manager.h" +#include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include #include +#include #include #include #include @@ -91,7 +94,8 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, auto *chatLayout = new QVBoxLayout(chatGroup); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &DlgReportUser::applyChatLogFont); if (!_autoChatLog.isEmpty()) { chatLogEdit->setPlainText(_autoChatLog); } else { @@ -120,6 +124,11 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, layout->addWidget(buttonBox); } +void DlgReportUser::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void DlgReportUser::actSubmit() { const QString description = descriptionEdit->toPlainText().trimmed(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h index c7fce055d..0f2fa5080 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -27,6 +27,7 @@ public: private slots: void actSubmit(); void reportResponse(const Response &response); + void applyChatLogFont(); private: static QString categoryLabel(const QString &key); diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index 037a4a1e5..c57f65352 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -4,6 +4,7 @@ #include "../../client/sound_engine.h" #include "../../interface/card_localization.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../tabs/tab_supervisor.h" #include "../user/user_context_menu.h" #include "../user/user_list_manager.h" @@ -63,6 +64,9 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ChatView::adjustColorsToPalette); + applyChatFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &ChatView::applyChatFont); + userContextMenu = new UserContextMenu(tabSupervisor, this, game); connect(userContextMenu, SIGNAL(openMessageDialog(QString, bool)), this, SIGNAL(openMessageDialog(QString, bool))); @@ -108,6 +112,16 @@ void ChatView::adjustColorsToPalette() QTimer::singleShot(0, this, &ChatView::refreshBlockColors); } +void ChatView::applyChatFont() +{ + // Family comes from the ChatLog theme role; the message char formats carry + // no family of their own, so they (and new content) follow the widget and + // document default fonts. Weights/italics stay caller-side as before. + const QFont chatFont = themeManager->appFont(AppFont::ChatLog); + setFont(chatFont); + document()->setDefaultFont(chatFont); +} + void ChatView::refreshBlockColors() { QTextDocument *doc = document(); diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 3fa7224e5..a2e2f33dd 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -111,6 +111,7 @@ private slots: void openLink(const QUrl &link); void actMessageClicked(); void adjustColorsToPalette(); + void applyChatFont(); void refreshBlockColors(); void onScrollBarRangeChanged(); void onScrollBarValueChanged(int value); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 988c3e7d9..7986286b3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -1,5 +1,6 @@ #include "tab_report.h" +#include "../interface/theme_manager.h" #include "../server/user/user_context_menu.h" #include "../utility/report_utils.h" #include "abstract_client.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -137,7 +139,8 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T chatGroup = new QGroupBox; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); @@ -269,6 +272,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T refreshList(); } +void TabReport::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void TabReport::retranslateUi() { userContextMenu->retranslateUi(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 32ebe1a3f..43e778f4b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -62,6 +62,7 @@ private slots: void statsResponse(const Response &response); void reopenReport(); void reopenResponse(const Response &response); + void applyChatLogFont(); private: int selectedReportId() const; diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index d7fc083c6..df2400b14 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -1,5 +1,6 @@ #include "report_utils.h" +#include "../../theme_manager.h" #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include @@ -7,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +21,20 @@ namespace report_utils { +QFont reportLogFont() +{ + if (themeManager->hasAppFont(AppFont::ChatLog)) { + return themeManager->appFont(AppFont::ChatLog); + } + QFont monoFont(QStringLiteral("monospace")); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + return monoFont; +} + namespace { QColor reportStatusColor(const QString &status) diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h index 679989486..32cc19eac 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.h +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -1,6 +1,7 @@ #ifndef REPORT_UTILS_H #define REPORT_UTILS_H +#include #include #include @@ -11,6 +12,12 @@ class ServerInfo_Report; namespace report_utils { +/** @brief Font for the report chat-log views: the theme's ChatLog role when it + * sets one, otherwise the monospace font these views used before the + * role existed. Shared by the three report surfaces so they cannot + * drift apart. */ +QFont reportLogFont(); + QString formatReportCategory(const QString &category); QString formatReportTime(qint64 secondsSinceEpoch); From 396196b7621e58bf8ded1d73d0243f0bf7c8b9e2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:54 +0100 Subject: [PATCH 220/247] [Fonts] Migrate CardText surfaces to theme font role (#7538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Migrate CardText surfaces to theme font role * [Fonts] Keep CardText call sites on the app font and fix px sizes - CardText's unset fallback is qApp->font() again: only the three counter painters (paintNumberEllipse, GeneralCounter, PlayerCounter) were Serif before the role existed, and they now ask for it through ThemeManager::cardTextFont(). Card names, player names, counters, the overlay, the Archidekt and EDHREC labels and the completer delegate keep the app font they used before. - Use setPixelSize for the EDHREC card name (20px) and commander name (28px) and details (16px) sizes: the stylesheets said px, and setPointSize made them about a third larger. --------- Co-authored-by: Lukas Brübach --- .../board/abstract_card_item.cpp | 3 ++- .../board/abstract_graphics_item.cpp | 4 ++- .../game_graphics/board/counter_general.cpp | 3 ++- .../game_graphics/player/player_target.cpp | 5 ++-- cockatrice/src/interface/theme_manager.cpp | 23 ++++++++++------- cockatrice/src/interface/theme_manager.h | 3 +++ .../widgets/cards/art_crop_attribution.cpp | 4 +++ ..._info_picture_with_text_overlay_widget.cpp | 18 ++++++++++--- ...rd_info_picture_with_text_overlay_widget.h | 3 +++ .../all_zones_card_amount_widget.cpp | 12 +++++++-- .../all_zones_card_amount_widget.h | 3 +++ .../printing_selector/card_amount_widget.cpp | 12 +++++++-- .../printing_selector/card_amount_widget.h | 1 + ...dekt_deck_preview_image_display_widget.cpp | 23 +++++++++++------ ...hidekt_deck_preview_image_display_widget.h | 3 +++ ...i_response_card_details_display_widget.cpp | 17 ++++++++++++- ...api_response_card_details_display_widget.h | 3 +++ ...ponse_commander_details_display_widget.cpp | 25 ++++++++++++++++--- ...esponse_commander_details_display_widget.h | 3 +++ .../utility/card_completer_delegate.cpp | 7 +++--- 20 files changed, 140 insertions(+), 35 deletions(-) diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 0e8b6ced3..84e062cd7 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../../interface/theme_manager.h" #include "../card_dimensions.h" #include "../game_scene.h" #include "../z_values.h" @@ -154,7 +155,7 @@ void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &transla painter->rotate(angle); painter->translate(-translatedSize.width() / 2, -translatedSize.height() / 2); - QFont f; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(fontSize * scale); painter->setFont(f); diff --git a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp index 50e80d359..3a740aa98 100644 --- a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp @@ -1,5 +1,7 @@ #include "abstract_graphics_item.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -20,7 +22,7 @@ void AbstractGraphicsItem::paintNumberEllipse(int number, painter->save(); QString numStr = QString::number(number); - QFont font("Serif"); + QFont font = themeManager->cardTextFont(); font.setPixelSize(fontSize); font.setWeight(QFont::Bold); diff --git a/cockatrice/src/game_graphics/board/counter_general.cpp b/cockatrice/src/game_graphics/board/counter_general.cpp index 4a8bbd6a4..23618e6bf 100644 --- a/cockatrice/src/game_graphics/board/counter_general.cpp +++ b/cockatrice/src/game_graphics/board/counter_general.cpp @@ -1,6 +1,7 @@ #include "counter_general.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "abstract_graphics_item.h" #include @@ -41,7 +42,7 @@ void GeneralCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * / painter->drawPixmap(QPoint(0, 0), pixmap); if (value) { - QFont f("Serif"); + QFont f = themeManager->cardTextFont(); f.setPixelSize(qMax((int)(radius * scaleFactor), 10)); f.setWeight(QFont::Bold); painter->setPen(Qt::black); diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 1cd97e7b5..0d10ba4ab 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -2,6 +2,7 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../board/abstract_counter.h" #include "../board/abstract_graphics_item.h" #include "../board/arrow_target.h" @@ -76,7 +77,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /* QRectF translatedRect = path.controlPointRect(); QSize translatedSize = translatedRect.size().toSize(); - QFont font("Serif"); + QFont font = themeManager->cardTextFont(); font.setWeight(QFont::Bold); font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9)); painter->setFont(font); @@ -226,7 +227,7 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o name = name.mid(0, 10) + "..."; } - QFont font; + QFont font = themeManager->appFont(AppFont::CardText); font.setPixelSize(qMax(qRound(translatedNameRect.height() / 1.5), 9)); painter->setFont(font); painter->setPen(Qt::white); diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 8d538c134..fdd6bbc30 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -660,15 +660,10 @@ QFont ThemeManager::appFont(AppFont::Role role) const } // Role fallbacks preserve the pre-theme defaults when no fonts.toml is - // present: card text starts from a serif basis, everything else from the - // application font. The report dialogs that used to hardcode monospace ask - // for it explicitly (report_utils::reportLogFont). - if (role == AppFont::CardText) { - return QFont(QStringLiteral("Serif")); - } - - // No per-role scaling: every other unset role resolves to the application - // font, so with no fonts.toml present the result is identical to before. + // present: an unset role resolves to the application font, so the result is + // identical to before. The report dialogs that used to hardcode monospace + // ask for it explicitly (report_utils::reportLogFont), and the counter + // painters that used to hardcode Serif ask for it through cardTextFont(). return qApp->font(); } @@ -677,6 +672,16 @@ bool ThemeManager::hasAppFont(AppFont::Role role) const return currentFonts.fonts.contains(role); } +QFont ThemeManager::cardTextFont() const +{ + if (hasAppFont(AppFont::CardText)) { + return appFont(AppFont::CardText); + } + QFont serifFont(QStringLiteral("Serif")); + serifFont.setStyleHint(QFont::Serif); + return serifFont; +} + QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const { // No per-role scaling and no preview: an unset role resolves to the font diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index aba91faf7..35cc36d04 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -155,6 +155,9 @@ public: QFont appFont(AppFont::Role role) const; /** @brief Whether the active theme defines this role in fonts.toml. */ bool hasAppFont(AppFont::Role role) const; + /** @brief The CardText role with its historical Serif fallback. Used by the + * counter painters, which were Serif before the role existed. */ + QFont cardTextFont() const; /** @brief Resolves an application font role against the startup font only, * ignoring any previewed config in currentFonts. Editors that show * "what this role resolves to" must use this, because appFont() diff --git a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp index c4b347047..8475a0ed9 100644 --- a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp @@ -1,5 +1,6 @@ #include "art_crop_attribution.h" +#include "../../theme_manager.h" #include "libcockatrice/card/printing/printing_info.h" #include @@ -35,7 +36,10 @@ QRectF paintArtAttribution(QPainter &painter, painter.save(); + // Family comes from the CardText theme role; the incoming painter size + // stays the basis for the scale factor exactly as before. QFont font = painter.font(); + font.setFamily(themeManager->appFont(AppFont::CardText).family()); font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale)); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index 04fe9ab69..56fe0bda4 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -1,5 +1,7 @@ #include "card_info_picture_with_text_overlay_widget.h" +#include "../../theme_manager.h" + #include #include #include @@ -43,6 +45,7 @@ CardInfoPictureWithTextOverlayWidget::CardInfoPictureWithTextOverlayWidget(QWidg fontSize(fontSize), textAlignment(alignment), highlighted(false) { this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + connect(themeManager, &ThemeManager::fontsChanged, this, &CardInfoPictureWithTextOverlayWidget::applyCardTextFont); } /** @@ -97,6 +100,13 @@ void CardInfoPictureWithTextOverlayWidget::setTextAlignment(const Qt::Alignment update(); } +void CardInfoPictureWithTextOverlayWidget::applyCardTextFont() +{ + // The overlay paint reads the CardText role font at paint time, so a + // theme font change only needs a repaint here. + update(); +} + void CardInfoPictureWithTextOverlayWidget::setHighlighted(bool _highlighted) { if (highlighted == _highlighted) { @@ -168,8 +178,10 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) return; } - // Calculate the optimal font size - QFont font = painter.font(); + // Calculate the optimal font size. The family comes from the CardText + // theme role; the fontSize member stays the starting size and the + // shrink-to-fit logic below is unchanged. + QFont font = themeManager->appFont(AppFont::CardText); int optimalFontSize = fontSize; // Start with the user-defined font size const QFontMetrics baseMetrics(font); int textWidth = pixmapRect.width(); @@ -284,7 +296,7 @@ QSize CardInfoPictureWithTextOverlayWidget::minimumSizeHint() const const QSize pixmapSize = pixmap.isNull() ? QSize(0, 0) : pixmap.size(); // Get the font metrics for the overlay text - QFont font; + QFont font = themeManager->appFont(AppFont::CardText); font.setPointSize(fontSize); const QFontMetrics fontMetrics(font); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h index 6c547dc13..41019f543 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h @@ -47,6 +47,9 @@ protected: void mousePressEvent(QMouseEvent *event) override; [[nodiscard]] QSize minimumSizeHint() const override; +private slots: + void applyCardTextFont(); + private: void drawOutlinedText(QPainter &painter, const QRect &textRect, diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp index bad88ddbb..13a43bd47 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp @@ -1,5 +1,6 @@ #include "all_zones_card_amount_widget.h" +#include "../../theme_manager.h" #include "../general/display/shadow_background_label.h" #include "card_amount_widget.h" #include "libcockatrice/card/card_info.h" @@ -67,6 +68,7 @@ AllZonesCardAmountWidget::AllZonesCardAmountWidget(QWidget *parent, buttonBoxSideboard->setVisible(!isToken); connect(cardSizeSlider, &QSlider::valueChanged, this, &AllZonesCardAmountWidget::adjustFontSize); + connect(themeManager, &ThemeManager::fontsChanged, this, &AllZonesCardAmountWidget::applyCardTextFont); QTimer::singleShot(10, this, [this]() { adjustFontSize(this->cardSizeSlider->value()); }); @@ -90,8 +92,9 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage) int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225; newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize); - // Update the font labels - QFont zoneLabelFont = zoneLabelMainboard->font(); + // Update the font labels. The family comes from the CardText theme + // role; the slider-driven point size stays as before. + QFont zoneLabelFont = themeManager->appFont(AppFont::CardText); zoneLabelFont.setPointSize(newFontSize); zoneLabelMainboard->setFont(zoneLabelFont); zoneLabelSideboard->setFont(zoneLabelFont); @@ -101,6 +104,11 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage) repaint(); } +void AllZonesCardAmountWidget::applyCardTextFont() +{ + adjustFontSize(cardSizeSlider->value()); +} + void AllZonesCardAmountWidget::setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount) { buttonBoxMainboard->setAmount(mainboardAmount); diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h index cefabe293..3213cf16b 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h @@ -36,6 +36,9 @@ public slots: void adjustFontSize(int scalePercentage); void setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount); +private slots: + void applyCardTextFont(); + private: QVBoxLayout *layout; QSlider *cardSizeSlider; diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp index eae49d7ef..7f25ea1f4 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp @@ -1,5 +1,6 @@ #include "card_amount_widget.h" +#include "../../theme_manager.h" #include "../deck_editor/deck_state_manager.h" #include "../general/display/dynamic_font_size_push_button.h" #include "deck_list_model.h" @@ -77,6 +78,7 @@ CardAmountWidget::CardAmountWidget(QWidget *parent, // Connect slider for dynamic font size adjustment connect(cardSizeSlider, &QSlider::valueChanged, this, &CardAmountWidget::adjustFontSize); + connect(themeManager, &ThemeManager::fontsChanged, this, &CardAmountWidget::applyCardTextFont); } int CardAmountWidget::getAmount() @@ -138,8 +140,9 @@ void CardAmountWidget::adjustFontSize(int scalePercentage) int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225; newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize); - // Update the font for card count label - QFont cardCountFont = cardCountInZone->font(); + // Update the font for card count label. The family comes from the + // CardText theme role; the slider-driven point size stays as before. + QFont cardCountFont = themeManager->appFont(AppFont::CardText); cardCountFont.setPointSize(newFontSize); cardCountInZone->setFont(cardCountFont); @@ -150,6 +153,11 @@ void CardAmountWidget::adjustFontSize(int scalePercentage) repaint(); } +void CardAmountWidget::applyCardTextFont() +{ + adjustFontSize(cardSizeSlider->value()); +} + /** * @brief Updates the card count display in the widget. */ diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h index 6f5889fc7..cef872157 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h @@ -67,6 +67,7 @@ private slots: void removePrintingSideboard(); void removePrintingTokensboard(); void adjustFontSize(int scalePercentage); + void applyCardTextFont(); }; #endif // CARD_AMOUNT_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp index 97383534f..3720aeb55 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp @@ -1,5 +1,6 @@ #include "archidekt_deck_preview_image_display_widget.h" +#include "../../../../../theme_manager.h" #include "../../../../general/display/shadow_background_label.h" #include @@ -17,13 +18,9 @@ ArchidektDeckPreviewImageDisplayWidget::ArchidektDeckPreviewImageDisplayWidget(Q bottomLeftLabel = new ShadowBackgroundLabel(this, ""); bottomRightLabel = new ShadowBackgroundLabel(this, ""); - QFont f; - f.setBold(true); - f.setPointSize(16); - topLeftLabel->setFont(f); - topRightLabel->setFont(f); - bottomLeftLabel->setFont(f); - bottomRightLabel->setFont(f); + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &ArchidektDeckPreviewImageDisplayWidget::applyCardTextFont); // Raise so labels appear above image topLeftLabel->raise(); @@ -32,6 +29,18 @@ ArchidektDeckPreviewImageDisplayWidget::ArchidektDeckPreviewImageDisplayWidget(Q bottomRightLabel->raise(); } +void ArchidektDeckPreviewImageDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; size and weight stay caller-side as before. + QFont f = themeManager->appFont(AppFont::CardText); + f.setBold(true); + f.setPointSize(16); + topLeftLabel->setFont(f); + topRightLabel->setFont(f); + bottomLeftLabel->setFont(f); + bottomRightLabel->setFont(f); +} + void ArchidektDeckPreviewImageDisplayWidget::setAspectRatio(float ratio) { aspectRatio = ratio; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h index 991f27b6a..41585e851 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h @@ -60,6 +60,9 @@ protected: */ void resizeEvent(QResizeEvent *event) override; +private slots: + void applyCardTextFont(); + private: float aspectRatio = 1.0f; ///< Aspect ratio to maintain for the preview image }; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index b2ddace01..2b76de973 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -1,5 +1,6 @@ #include "edhrec_api_response_card_details_display_widget.h" +#include "../../../../../../theme_manager.h" #include "../../../../../cards/card_info_picture_widget.h" #include "../../../../../cards/card_size_widget.h" #include "../../../../../general/display/background_plate_widget.h" @@ -9,6 +10,7 @@ #include "edhrec_api_response_card_synergy_display_widget.h" #include "libcockatrice/card/database/card_database_querier.h" +#include #include #include #include @@ -32,7 +34,9 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi nameLabel = new QLabel(this); nameLabel->setText(toDisplay.name); nameLabel->setAlignment(Qt::AlignHCenter); - nameLabel->setStyleSheet("font-size: 20px; font-weight: bold"); + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecApiResponseCardDetailsDisplayWidget::applyCardTextFont); inclusionDisplayWidget = new EdhrecApiResponseCardInclusionDisplayWidget(this, toDisplay); @@ -86,3 +90,14 @@ void EdhrecApiResponseCardDetailsDisplayWidget::actRequestPageNavigation() { emit requestUrl(toDisplay.url); } + +void EdhrecApiResponseCardDetailsDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; the 20px size and bold + // weight previously carried by the stylesheet stay caller-side. Pixel + // size, not point size: the stylesheet said 20px, and 20pt is ~27px. + QFont cardFont = themeManager->appFont(AppFont::CardText); + cardFont.setPixelSize(20); + cardFont.setWeight(QFont::Bold); + nameLabel->setFont(cardFont); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h index d738f4685..8a3c8fba4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h @@ -30,6 +30,9 @@ public slots: signals: void requestUrl(QString url); +private slots: + void applyCardTextFont(); + private: EdhrecApiResponseCardDetails toDisplay; QVBoxLayout *layout; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 2ff22f2b3..df0319f88 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -1,5 +1,6 @@ #include "edhrec_api_response_commander_details_display_widget.h" +#include "../../../../../../theme_manager.h" #include "../../../../../cards/card_info_picture_widget.h" #include "../../../../../cards/card_size_widget.h" #include "../../api_response/cards/edhrec_commander_api_response_commander_details.h" @@ -8,6 +9,7 @@ #include "edhrec_commander_api_response_navigation_widget.h" #include "libcockatrice/card/database/card_database_querier.h" +#include #include #include #include @@ -48,15 +50,16 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom commanderName = new QLabel(this); commanderName->setText(commanderDetails.getName()); commanderName->setAlignment(Qt::AlignCenter); - commanderName->setStyleSheet("font-size: 28px; font-weight: bold"); label = new QLabel(this); label->setAlignment(Qt::AlignCenter); - label->setStyleSheet("font-size: 16px"); salt = new QLabel(this); salt->setAlignment(Qt::AlignCenter); - salt->setStyleSheet("font-size: 16px"); + + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderResponseCommanderDetailsDisplayWidget::applyCardTextFont); cardPricesDisplayWidget = new EdhrecApiResponseCardPricesDisplayWidget(this, commanderDetails.getPrices()); @@ -83,3 +86,19 @@ void EdhrecCommanderResponseCommanderDetailsDisplayWidget::retranslateUi() label->setText(commanderDetails.getLabel()); salt->setText(tr("Salt: ") + QString::number(commanderDetails.getSalt())); } + +void EdhrecCommanderResponseCommanderDetailsDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; the sizes and the bold + // weight previously carried by the stylesheets stay caller-side. Pixel + // sizes, not point sizes: the stylesheets said 28px and 16px. + QFont nameFont = themeManager->appFont(AppFont::CardText); + nameFont.setPixelSize(28); + nameFont.setWeight(QFont::Bold); + commanderName->setFont(nameFont); + + QFont detailsFont = themeManager->appFont(AppFont::CardText); + detailsFont.setPixelSize(16); + label->setFont(detailsFont); + salt->setFont(detailsFont); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h index 197263512..99e6b3d43 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h @@ -30,6 +30,9 @@ public: QString baseUrl); void retranslateUi(); +private slots: + void applyCardTextFont(); + private: EdhrecCommanderApiResponseCommanderDetails commanderDetails; QHBoxLayout *layout; diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index a629df43c..0090e9d65 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -1,5 +1,6 @@ #include "card_completer_delegate.h" +#include "../../interface/theme_manager.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/mana_cost_widget.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -182,7 +183,7 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt p->setBrush(Qt::NoBrush); p->drawEllipse(pip.adjusted(1, 1, -1, -1)); - QFont f = QtUtils::boldFont(p->font()); + QFont f = QtUtils::boldFont(themeManager->appFont(AppFont::CardText)); f.setPixelSize(qMax(radius - 1, 7)); p->setFont(f); @@ -429,7 +430,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem const QRect nameRect = showInfoBand ? QRect(textLeft, rect.top() + 2, textWidth, 20) : QRect(textLeft, rect.top() + 2, textWidth, rect.height() - 4); - QFont f = option.font; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(13); f.setBold(true); @@ -451,7 +452,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem if (showInfoBand) { const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24); - QFont f = option.font; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(10); painter->setFont(f); From 3bcdfc37931077dc2bb87c29c9a8877953145f1d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:54 +0100 Subject: [PATCH 221/247] [Fonts] Migrate DeckList and InterfaceDefault surfaces to theme roles (#7539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Migrate DeckList and InterfaceDefault surfaces to theme roles * [Fonts] Drop unused lambda captures in font-refresh handlers * [Fonts] Fix pixel sizes and game overlay fonts from review - Set the sizes the stylesheets carried with setPixelSize() rather than setPointSize(): home buttons (34), banner (24), home group box (20), the placeholder labels (14), the analytics drag handle (10), the EDHREC navigation buttons (24), the user info popup (11/14/11/10/11) and the game overlays (14/16/12). Points at 96 DPI were ~33% larger. - The drag/total counters and tally columns keep the monospace family the stylesheets set, so digit widths stop changing as counts tick and white-space: pre stays aligned. - applyDeckListFont() drops the cached tally rows before re-measuring, so previewing a larger DeckList font no longer leaves the tally clipped. - Tally labels and the constructor/fontsChanged pairs in the shared-deck preview, quick setup, analytics panel, home box and public decks build their font once in a local lambda instead of twice. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/shortcut_treeview.cpp | 4 +- cockatrice/src/filters/filter_tree_model.cpp | 5 +- .../src/game_graphics/deckview/deck_view.cpp | 6 ++- cockatrice/src/game_graphics/game_view.cpp | 52 ++++++++++++++++--- cockatrice/src/game_graphics/game_view.h | 1 + .../src/interface/deck_loader/deck_loader.cpp | 9 +++- .../interface/palette_editor/color_button.cpp | 6 ++- .../palette_editor/palette_grid_widget.cpp | 17 +++++- .../palette_editor/palette_grid_widget.h | 4 ++ .../palette_editor/quick_setup_panel.cpp | 14 +++-- .../deck_analytics/resizable_panel.cpp | 12 ++++- .../deck_editor/deck_list_style_proxy.cpp | 5 +- .../dialogs/dlg_shared_decks_preview.cpp | 12 +++-- .../widgets/general/display/banner_widget.cpp | 16 +++++- .../widgets/general/display/banner_widget.h | 1 + .../display/charts/bars/bar_chart_widget.cpp | 5 +- .../charts/bars/segmented_bar_widget.cpp | 5 +- .../display/dynamic_font_size_label.cpp | 13 ++++- .../display/dynamic_font_size_push_button.cpp | 7 ++- .../widgets/general/home_styled_button.cpp | 18 +++++-- .../widgets/general/home_styled_button.h | 3 ++ .../interface/widgets/general/home_widget.cpp | 15 ++++-- .../widgets/onboarding/first_run_wizard.cpp | 16 ++++-- .../widgets/onboarding/first_run_wizard.h | 1 + .../printing_selector_placeholder_widget.cpp | 15 +++++- .../printing_selector_placeholder_widget.h | 3 ++ .../widgets/server/user/user_info_box.cpp | 17 ++++-- .../widgets/server/user/user_info_box.h | 1 + .../widgets/server/user/user_info_popup.cpp | 44 +++++++++++----- .../widgets/server/user/user_list_painter.cpp | 9 +++- .../widgets/server/user/user_list_widget.cpp | 2 +- .../settings_search_delegate.cpp | 6 ++- ...api_response_bracket_navigation_widget.cpp | 25 +++++++-- ...r_api_response_bracket_navigation_widget.h | 3 ++ ..._api_response_budget_navigation_widget.cpp | 25 +++++++-- ...er_api_response_budget_navigation_widget.h | 3 ++ ...mmander_api_response_navigation_widget.cpp | 19 ++++++- ...commander_api_response_navigation_widget.h | 3 ++ .../interface/widgets/tabs/tab_developer.cpp | 4 +- .../widgets/tabs/tab_public_decks.cpp | 8 ++- .../visual_deck_editor_placeholder_widget.cpp | 15 +++++- .../visual_deck_editor_placeholder_widget.h | 3 ++ .../deck_preview_tag_addition_widget.cpp | 7 ++- .../deck_preview_tag_display_widget.cpp | 7 ++- 44 files changed, 387 insertions(+), 79 deletions(-) diff --git a/cockatrice/src/client/settings/shortcut_treeview.cpp b/cockatrice/src/client/settings/shortcut_treeview.cpp index 4a5bafb01..d81589f34 100644 --- a/cockatrice/src/client/settings/shortcut_treeview.cpp +++ b/cockatrice/src/client/settings/shortcut_treeview.cpp @@ -1,5 +1,6 @@ #include "shortcut_treeview.h" +#include "../../interface/theme_manager.h" #include "cache_settings.h" #include "shortcuts_settings.h" @@ -93,7 +94,8 @@ void ShortcutTreeView::populateShortcutsModel() curParent = parentItems.value(group); } else { curParent = new QStandardItem(group); - static QFont font = curParent->font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); curParent->setFont(font); parentItems.insert(group, curParent); diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index 9b55ce2fd..467c2564b 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -1,5 +1,7 @@ #include "filter_tree_model.h" +#include "../interface/theme_manager.h" + #include #include #include @@ -202,7 +204,8 @@ QVariant FilterTreeModel::data(const QModelIndex &index, int role) const switch (role) { case Qt::FontRole: if (!node->isLeaf()) { - QFont f; + // Family comes from the DeckList theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::DeckList); f.setBold(true); return f; } diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 6e0b0eaaa..63ee48e57 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -240,7 +240,8 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI painter->drawLine(QPointF(0, separatorY), QPointF(width, separatorY)); painter->setPen(QColor(Qt::white)); - QFont f("Serif"); + // Family comes from the DeckList theme role; pixel sizes/weights unchanged. + QFont f = themeManager->appFont(AppFont::DeckList); f.setStyleHint(QFont::Serif); f.setPixelSize(24); f.setWeight(QFont::Bold); @@ -292,7 +293,8 @@ QList> DeckViewCardContainer::getRowsAndCols() const int DeckViewCardContainer::getCardTypeTextWidth() const { - QFont f("Serif"); + // Family comes from the DeckList theme role; pixel size/weight unchanged. + QFont f = themeManager->appFont(AppFont::DeckList); f.setStyleHint(QFont::Serif); f.setPixelSize(16); f.setWeight(QFont::Bold); diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index e6d94a035..7a5aa073a 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -2,12 +2,14 @@ #include "../client/settings/cache_settings.h" #include "../client/settings/shortcuts_settings.h" +#include "../interface/theme_manager.h" #include "game_scene.h" #include #include #include #include +#include #include #include #include @@ -24,6 +26,21 @@ #include #include +namespace +{ +QFont deckOverlayFont(int pixelSize, bool bold) +{ + // The drag/total counters and the tally columns are in-game overlays, not + // deck list text: keep the monospace family the stylesheets used to set so + // digit widths stay stable as counts tick and `white-space: pre` lines up. + QFont font(QStringLiteral("monospace")); + font.setStyleHint(QFont::Monospace); + font.setPixelSize(pixelSize); + font.setBold(bold); + return font; +} +} // namespace + // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. // This subclass disables that behavior so dragCountLabel can appear above it. class SelectionRubberBand : public QRubberBand @@ -76,15 +93,14 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this); const QString baseProperties = "color: white; " - "font-family: monospace; " "background-color: rgba(0, 0, 0, 160); " "border-radius: 3px; " "padding: 1px 2px; " "white-space: pre;"; - const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;"; - const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;"; - const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;"; + const QString dragCountLabelStyle = baseProperties; + const QString totalCountLabelStyle = baseProperties; + const QString subtypeTallyLabelStyle = baseProperties; dragCountLabel = new QLabel(this); dragCountLabel->setStyleSheet(dragCountLabelStyle); @@ -101,6 +117,27 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par tallyLayout->setContentsMargins(2, 2, 2, 2); tallyLayout->setSpacing(2); tallyContainer->hide(); + + applyDeckOverlayFonts(); +} + +void GameView::applyDeckOverlayFonts() +{ + dragCountLabel->setFont(deckOverlayFont(14, true)); + totalCountLabel->setFont(deckOverlayFont(16, true)); + tallyContainer->setFont(deckOverlayFont(12, false)); + for (int i = 0; i < tallyLayout->count(); ++i) { + if (auto *label = qobject_cast(tallyLayout->itemAt(i)->widget())) { + // Count column (odd positions) keeps the bold emphasis. + const bool count = i % 2 == 1; + label->setFont(deckOverlayFont(count ? 14 : 12, count)); + } + } + + // The container keeps the size computed from the previous fonts, so force + // rebuildTallyLabels() to re-measure it instead of reusing cachedTallyRows. + cachedTallyRows.clear(); + updateTotalSelectionCount(); } void GameView::resizeEvent(QResizeEvent *event) @@ -220,9 +257,8 @@ QSize GameView::rebuildTallyLabels(const QList &entries) { clearTallyLabels(); - const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;"); - const QString countStyle = - QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;"); + const QString nameStyle = QStringLiteral("color: white; background: transparent;"); + const QString countStyle = QStringLiteral("color: white; background: transparent;"); int totalHeight = 0; int maxNameWidth = 0; @@ -232,11 +268,13 @@ QSize GameView::rebuildTallyLabels(const QList &entries) for (const TallyRow &entry : entries) { auto *nameLabel = new QLabel(entry.name, tallyContainer); nameLabel->setStyleSheet(nameStyle); + nameLabel->setFont(deckOverlayFont(12, false)); nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); tallyLayout->addWidget(nameLabel, row, 0); auto *countLabel = new QLabel(entry.value, tallyContainer); countLabel->setStyleSheet(countStyle); + countLabel->setFont(deckOverlayFont(14, true)); countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); tallyLayout->addWidget(countLabel, row, 1); diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index e57267734..99a73a0a8 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -50,6 +50,7 @@ private slots: void refreshShortcuts(); void updateTotalSelectionCount(const QSize &viewSize = QSize()); void setFocusDisabled(bool disabled); + void applyDeckOverlayFonts(); public slots: void updateSceneRect(const QRectF &rect); diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index d413a6c04..d5c7de9a4 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -1,5 +1,6 @@ #include "deck_loader.h" +#include "../theme_manager.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h" @@ -579,6 +580,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode if (!cards.isEmpty()) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; + // Family comes from the DeckList theme role; sizes/weights unchanged. + charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); charFormat.setFontPointSize(11); charFormat.setFontWeight(QFont::Bold); cursor->insertBlock(blockFormat, charFormat); @@ -592,6 +595,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; + cellCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); cellCharFormat.setFontPointSize(9); QTextTableCell cell = table->cellAt(i, 0); @@ -613,6 +617,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode QTextBlockFormat blockFormat; QTextCharFormat charFormat; + charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); charFormat.setFontPointSize(14); charFormat.setFontWeight(QFont::Bold); @@ -640,7 +645,8 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList) { QTextDocument doc; - QFont font("Serif"); + // Family comes from the DeckList theme role; the print sizes/weights below stay unchanged. + QFont font = themeManager->appFont(AppFont::DeckList); font.setStyleHint(QFont::Serif); doc.setDefaultFont(font); @@ -648,6 +654,7 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList) QTextBlockFormat headerBlockFormat; QTextCharFormat headerCharFormat; + headerCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); headerCharFormat.setFontPointSize(16); headerCharFormat.setFontWeight(QFont::Bold); diff --git a/cockatrice/src/interface/palette_editor/color_button.cpp b/cockatrice/src/interface/palette_editor/color_button.cpp index a7db23d31..ae9bd6297 100644 --- a/cockatrice/src/interface/palette_editor/color_button.cpp +++ b/cockatrice/src/interface/palette_editor/color_button.cpp @@ -1,5 +1,7 @@ #include "color_button.h" +#include "../theme_manager.h" + #include #include #include @@ -21,6 +23,7 @@ ColorButton::ColorButton(QWidget *parent) : QToolButton(parent) setCursor(Qt::PointingHandCursor); setToolTip(tr("Click to pick a color")); connect(this, &QToolButton::clicked, this, &ColorButton::pickColor); + connect(themeManager, &ThemeManager::fontsChanged, this, &ColorButton::updateSwatch); } void ColorButton::setColor(const QColor &c) @@ -73,7 +76,8 @@ void ColorButton::updateSwatch() // Hex label — black or white for contrast int luma = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue(); painter.setPen((luma > 128 && color.alpha() > 80) ? QColor(0, 0, 0, 180) : QColor(255, 255, 255, 200)); - QFont f = font(); + // Family comes from the InterfaceDefault theme role; the 8px size stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setPixelSize(8); painter.setFont(f); painter.drawText(rect(), Qt::AlignCenter, color.name().toUpper()); diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 463c4038d..392f01d0d 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -76,6 +76,18 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) layout = QtUtils::makeLayout(this); layout->addWidget(scroll); + + connect(themeManager, &ThemeManager::fontsChanged, this, &PaletteGridWidget::applyInterfaceDefaultFont); +} + +void PaletteGridWidget::applyInterfaceDefaultFont() +{ + // The editor's own chrome follows the InterfaceDefault theme role; bold stays caller-side. + for (auto *label : headerLabels) { + QFont headerFont = themeManager->appFont(AppFont::InterfaceDefault); + headerFont.setBold(true); + label->setFont(headerFont); + } } void PaletteGridWidget::buildGrid(QWidget *host) @@ -101,7 +113,8 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *label = new QLabel(groupHeaders[col], host); label->setAlignment(Qt::AlignCenter); label->setToolTip(groupTips[col]); - label->setFont(QtUtils::boldFont(label->font())); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + label->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); label->setAutoFillBackground(true); label->setContentsMargins(4, 4, 4, 4); grid->addWidget(label, 0, col + 1); @@ -146,7 +159,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *appHeader = new QLabel(tr("App colors"), host); appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); - appHeader->setFont(QtUtils::boldFont(appHeader->font())); + appHeader->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); appHeader->setAutoFillBackground(true); appHeader->setContentsMargins(4, 4, 4, 4); grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.h b/cockatrice/src/interface/palette_editor/palette_grid_widget.h index f1ff41ec2..7c527a064 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -34,6 +34,10 @@ private: void changeEvent(QEvent *e); void refreshChromePalettes(); +private slots: + void applyInterfaceDefaultFont(); + +private: QMap> colorButtons; QMap appColorButtons; QScrollArea *scroll; diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp index 04f98dcc3..00eac3253 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp @@ -1,5 +1,6 @@ #include "quick_setup_panel.h" +#include "../theme_manager.h" #include "color_button.h" #include @@ -28,10 +29,15 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) labelLow = new QLabel(this); labelHigh = new QLabel(this); - QFont small = labelLow->font(); - small.setPointSizeF(small.pointSizeF() * 0.82); - labelLow->setFont(small); - labelHigh->setFont(small); + // Family comes from the InterfaceDefault theme role; the *0.82 size stays caller-side. + auto applySmallFont = [this]() { + QFont small = themeManager->appFont(AppFont::InterfaceDefault); + small.setPointSizeF(small.pointSizeF() * 0.82); + labelLow->setFont(small); + labelHigh->setFont(small); + }; + applySmallFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applySmallFont); QPalette dimmed = labelLow->palette(); dimmed.setColor(QPalette::WindowText, qApp->palette().color(QPalette::Mid)); labelLow->setPalette(dimmed); diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index 85dba6468..df2a645f1 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -1,5 +1,6 @@ #include "resizable_panel.h" +#include "../interface/theme_manager.h" #include "abstract_analytics_panel_widget.h" #include "libcockatrice/utility/qt_utils.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -93,9 +95,17 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid dragButton->setStyleSheet(QString("QPushButton { " "background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 %1, stop:1 %2); " - "border: none; color: %3; font-size: 10px; }" + "border: none; color: %3; }" "QPushButton:hover { background: %4; }") .arg(mid.name(), dark.name(), placeholderText.name(), midLight.name())); + // Family comes from the InterfaceDefault theme role; the 10px size stays caller-side. + auto applyDragFont = [this]() { + QFont dragFont = themeManager->appFont(AppFont::InterfaceDefault); + dragFont.setPixelSize(10); + dragButton->setFont(dragFont); + }; + applyDragFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyDragFont); resizeHandle->setStyleSheet(QString("QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, " "stop:0 %1, stop:1 %2); }") diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp index 1e1aefe58..59b7fff79 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp @@ -1,5 +1,7 @@ #include "deck_list_style_proxy.h" +#include "../../theme_manager.h" + #include #include #include @@ -20,7 +22,8 @@ QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const bool isCard = src.data(DeckRoles::IsCardRole).toBool(); if (role == Qt::FontRole && !isCard) { - QFont f; + // Family comes from the DeckList theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::DeckList); f.setBold(true); return f; } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp index d29aee27f..49bacd081 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -2,6 +2,7 @@ #include "../deck_share/shared_deck_preview_widget.h" #include "../general/layout_containers/flow_widget.h" +#include "../interface/theme_manager.h" #include #include @@ -35,9 +36,14 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, // QLabels render AutoText and markup would otherwise be shown as rich text. auto *titleLabel = new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); - QFont titleFont = QtUtils::boldFont(titleLabel->font()); - titleFont.setPointSize(titleFont.pointSize() + 2); - titleLabel->setFont(titleFont); + // Family comes from the InterfaceDefault theme role; the +2 size and bold stay caller-side. + auto applyTitleFont = [titleLabel]() { + QFont titleFont = QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault)); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + }; + applyTitleFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont); mainLayout->addWidget(titleLabel); if (!serverText.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index baf46bbba..5cb62db0f 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -1,9 +1,11 @@ #include "banner_widget.h" #include "../../../pixel_map_generator.h" +#include "../../../theme_manager.h" #include #include +#include #include #include #include @@ -28,7 +30,9 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation if (transparency > 50) { textColor = " color: white;"; } - bannerLabel->setStyleSheet("font-size: 24px; font-weight: bold;" + textColor); + bannerLabel->setStyleSheet(textColor); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &BannerWidget::applyInterfaceDefaultFont); layout->addWidget(iconLabel); layout->addWidget(bannerLabel); @@ -56,6 +60,16 @@ void BannerWidget::setText(const QString &text) const bannerLabel->setText(text); } +void BannerWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size and + // bold weight previously carried by the stylesheet stay caller-side. + QFont bannerFont = themeManager->appFont(AppFont::InterfaceDefault); + bannerFont.setPixelSize(24); + bannerFont.setWeight(QFont::Bold); + bannerLabel->setFont(bannerFont); +} + void BannerWidget::setClickable(bool _clickable) { clickable = _clickable; diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.h b/cockatrice/src/interface/widgets/general/display/banner_widget.h index 06556089b..d46250bdb 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.h +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.h @@ -48,6 +48,7 @@ signals: private slots: void toggleBuddyVisibility() const; void updateDropdownIconState() const; + void applyInterfaceDefaultFont(); }; #endif // BANNER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index 484ac861d..cd2a3702d 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp @@ -1,5 +1,7 @@ #include "bar_chart_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -146,7 +148,8 @@ void BarChartWidget::paintEvent(QPaintEvent *) // draw label below bar QRect labelRect(left + i * (barWidth + spacing), bottom, barWidth, LABEL_H); - QFont f = p.font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setBold(true); p.setFont(f); p.setPen(Qt::black); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp index 5a3130964..d6c75f3bd 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp @@ -1,5 +1,7 @@ #include "segmented_bar_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -95,7 +97,8 @@ void SegmentedBarWidget::paintEvent(QPaintEvent *) // draw label QRect labelRect(0, h - LABEL_H, w, LABEL_H); - QFont f = p.font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setBold(true); p.setFont(f); p.setPen(Qt::black); diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp index 0609eb16a..598c70fe2 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp @@ -1,5 +1,7 @@ #include "dynamic_font_size_label.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -28,9 +30,13 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event) // QElapsedTimer timer; // timer.start(); - QFont newFont = font(); + // Family follows the InterfaceDefault theme role; the fitted point size + // stays computed from the widget geometry as before. + QFont newFont = themeManager->appFont(AppFont::InterfaceDefault); float fontSize = getWidgetMaximumFontSize(this, this->text()); newFont.setPointSizeF(fontSize); + // Preserve the caller's bold emphasis, which the role font does not carry. + newFont.setBold(font().bold()); setFont(newFont); // qDebug() << "Font size set to" << fontSize; @@ -40,7 +46,10 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event) float DynamicFontSizeLabel::getWidgetMaximumFontSize(QWidget *widget, const QString &text) { - QFont font = widget->font(); + // Measure with the InterfaceDefault family so the fit matches the painted font. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); + font.setBold(widget->font().bold()); + font.setPointSizeF(widget->font().pointSizeF()); const QRect widgetRect = widget->contentsRect(); const float widgetWidth = widgetRect.width(); const float widgetHeight = widgetRect.height(); diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp index 25e8ca48f..9b5f5bdda 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp @@ -1,5 +1,6 @@ #include "dynamic_font_size_push_button.h" +#include "../../interface/theme_manager.h" #include "dynamic_font_size_label.h" #include @@ -20,10 +21,12 @@ void DynamicFontSizePushButton::paintEvent(QPaintEvent *event) // Call the base class paintEvent to preserve any other painting behavior QPushButton::paintEvent(event); - // Adjust the font size dynamically based on the text - QFont newFont = font(); + // Adjust the font size dynamically based on the text. Family follows the + // InterfaceDefault theme role; the fitted size stays computed as before. + QFont newFont = themeManager->appFont(AppFont::InterfaceDefault); float fontSize = DynamicFontSizeLabel::getWidgetMaximumFontSize(this, this->text()); newFont.setPointSizeF(fontSize); + newFont.setBold(font().bold()); setFont(newFont); // Get painter for custom painting diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.cpp b/cockatrice/src/interface/widgets/general/home_styled_button.cpp index 7f23827bc..9c31d3815 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.cpp +++ b/cockatrice/src/interface/widgets/general/home_styled_button.cpp @@ -1,6 +1,9 @@ #include "home_styled_button.h" +#include "../../theme_manager.h" + #include +#include #include #include #include @@ -21,6 +24,18 @@ HomeStyledButton::HomeStyledButton(const QString &text, QPair _g setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); setMinimumHeight(25); setStyleSheet(generateButtonStylesheet(gradientColors)); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &HomeStyledButton::applyInterfaceDefaultFont); +} + +void HomeStyledButton::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 34px size and + // bold weight previously carried by the stylesheet stay caller-side. + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(34); + buttonFont.setWeight(QFont::Bold); + setFont(buttonFont); } void HomeStyledButton::updateStylesheet(const QPair &colors) @@ -50,9 +65,6 @@ QString HomeStyledButton::generateButtonStylesheet(const QPair & return QString(R"( QPushButton { - font-size: 34px; - font-weight: bold; - font-family: sans-serif, "Segoe UI", "Helvetica Neue"; padding: 30px; color: white; border: 2px solid %1; diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.h b/cockatrice/src/interface/widgets/general/home_styled_button.h index 55ac5550f..5756db5a1 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.h +++ b/cockatrice/src/interface/widgets/general/home_styled_button.h @@ -24,6 +24,9 @@ public: public slots: void paintEvent(QPaintEvent *event) override; +private slots: + void applyInterfaceDefaultFont(); + private: QPair gradientColors; }; diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 5edf607be..e6b5bf255 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -291,7 +291,6 @@ QGroupBox *HomeWidget::createButtons() box->setObjectName("homeButtonsBox"); box->setStyleSheet(R"( QGroupBox { - font-size: 20px; color: white; /* Title text color */ background: transparent; } @@ -302,6 +301,14 @@ QGroupBox *HomeWidget::createButtons() subcontrol-position: top center; /* or top left / right */ } )"); + // Family comes from the InterfaceDefault theme role; the 20px size stays caller-side. + auto applyBoxFont = [box]() { + QFont boxFont = themeManager->appFont(AppFont::InterfaceDefault); + boxFont.setPixelSize(20); + box->setFont(boxFont); + }; + applyBoxFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyBoxFont); box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); @@ -472,7 +479,9 @@ void HomeWidget::paintEvent(QPaintEvent *event) constexpr int margin = 15; constexpr qreal attributionMargin = 4.0; - QFont attributionFont = painter.font(); + // Family comes from the InterfaceDefault theme role; the 14px sizes and + // bold weights stay caller-side as before. + QFont attributionFont = themeManager->appFont(AppFont::InterfaceDefault); attributionFont.setPointSize(14); attributionFont.setBold(true); painter.setFont(attributionFont); @@ -485,7 +494,7 @@ void HomeWidget::paintEvent(QPaintEvent *event) // Card name bubble above the attribution (when enabled). if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) { - QFont font = painter.font(); + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setPointSize(14); font.setBold(true); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index c4298d6b6..e69687c54 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -1,5 +1,6 @@ #include "first_run_wizard.h" +#include "../interface/theme_manager.h" #include "first_run_wizard_page.h" #include "pages/account_setup_page.h" #include "pages/card_database_setup_page.h" @@ -32,9 +33,8 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) bannerHost = new BannerHost(this); titleLabel = new QLabel(this); - QFont titleFont = QtUtils::boldFont(titleLabel->font()); - titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); - titleLabel->setFont(titleFont); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &FirstRunWizard::applyInterfaceDefaultFont); subtitleLabel = new QLabel(this); subtitleLabel->setWordWrap(true); @@ -154,6 +154,16 @@ void FirstRunWizard::updateChrome() } } +void FirstRunWizard::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the *1.4 size + // multiplier and bold weight stay caller-side as before. + QFont titleFont = themeManager->appFont(AppFont::InterfaceDefault); + titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); + titleFont.setBold(true); + titleLabel->setFont(titleFont); +} + void FirstRunWizard::goNext() { FirstRunWizardPage *page = pages[currentIndex]; diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h index 812985a9f..a5fa3b4bd 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -55,6 +55,7 @@ private slots: void goBack(); void skip(); void updateChrome(); + void applyInterfaceDefaultFont(); private: void addPage(FirstRunWizardPage *page); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp index 7f9ad66c8..225c3e21d 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp @@ -1,5 +1,8 @@ #include "printing_selector_placeholder_widget.h" +#include "../interface/theme_manager.h" + +#include #include #include #include @@ -30,10 +33,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa textLabel->setStyleSheet(R"( QLabel { color: palette(mid); - font-size: 14px; padding: 10px; } )"); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont); mainLayout->addWidget(imageLabel); mainLayout->addWidget(textLabel); @@ -44,4 +49,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa void PrintingSelectorPlaceholderWidget::retranslateUi() { textLabel->setText(tr("Select a card to view its available printings")); +} + +void PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 14px size stays caller-side. + QFont placeholderFont = themeManager->appFont(AppFont::InterfaceDefault); + placeholderFont.setPixelSize(14); + textLabel->setFont(placeholderFont); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h index 6702d325a..9e6e47bcb 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h @@ -14,6 +14,9 @@ class PrintingSelectorPlaceholderWidget : public QWidget public: explicit PrintingSelectorPlaceholderWidget(QWidget *parent = nullptr); +private slots: + void applyInterfaceDefaultFont(); + private: QVBoxLayout *mainLayout; QLabel *imageLabel; diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index fa7698037..997001c50 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -1,6 +1,7 @@ #include "user_info_box.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/dialogs/dlg_edit_avatar.h" #include "../../interface/widgets/dialogs/dlg_edit_password.h" #include "../../interface/widgets/dialogs/dlg_edit_user.h" @@ -39,10 +40,8 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *parent, Qt::WindowFlags flags) : QWidget(parent, flags), client(_client), editable(_editable) { - QFont nameFont = nameLabel.font(); - nameFont.setBold(true); - nameFont.setPointSizeF(nameFont.pointSizeF() * 1.5); - nameLabel.setFont(nameFont); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &UserInfoBox::applyInterfaceDefaultFont); auto *userIconAndNameLayout = new QHBoxLayout; userIconAndNameLayout->addWidget(&userLevelIcon, 0, Qt::AlignCenter); @@ -91,6 +90,16 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren retranslateUi(); } +void UserInfoBox::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the *1.5 size + // multiplier and bold weight stay caller-side as before. + QFont nameFont = themeManager->appFont(AppFont::InterfaceDefault); + nameFont.setBold(true); + nameFont.setPointSizeF(nameFont.pointSizeF() * 1.5); + nameLabel.setFont(nameFont); +} + void UserInfoBox::retranslateUi() { realNameLabel1.setText(tr("Real Name:")); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.h b/cockatrice/src/interface/widgets/server/user/user_info_box.h index 6bc571a37..98919b027 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.h @@ -48,6 +48,7 @@ private slots: void processEditResponse(const Response &r); void processPasswordResponse(const Response &r); void processAvatarResponse(const Response &r); + void applyInterfaceDefaultFont(); void actEdit(); void actEditInternal(const Response &r); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index 9f864d903..4b4887bc4 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -138,7 +138,8 @@ public: p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8)); // Game title (bold, elided) - QFont tf = option.font; + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont tf = themeManager->appFont(AppFont::InterfaceDefault); tf.setBold(true); p->setFont(tf); p->setPen(pal.color(QPalette::Text)); @@ -150,7 +151,7 @@ public: // Player count const bool full = game.player_count() >= game.max_players(); - p->setFont(option.font); + p->setFont(themeManager->appFont(AppFont::InterfaceDefault)); p->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text)); p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), Qt::AlignVCenter | Qt::AlignRight, @@ -319,7 +320,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) const int tx = ax + AvatarSize + AvatarToTextGap; const int tw = rect.width() - tx - 8; - QFont nf = font(); + // Family comes from the InterfaceDefault theme role; the *1.12 size and bold stay caller-side. + QFont nf = themeManager->appFont(AppFont::InterfaceDefault); nf.setBold(true); nf.setPointSizeF(nf.pointSizeF() * 1.12); p.setFont(nf); @@ -348,7 +350,7 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) } if (!badge.text.isEmpty()) { - QFont bf = font(); + QFont bf = themeManager->appFont(AppFont::InterfaceDefault); bf.setPointSizeF(bf.pointSizeF() * 0.70); bf.setBold(true); p.setFont(bf); @@ -365,7 +367,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) // The painter font at this point depends on whether a badge was drawn // (badge font vs username font), so pin an explicit font for the pill. - p.setFont(font()); + // Family follows the InterfaceDefault theme role like the rest of the popup chrome. + p.setFont(themeManager->appFont(AppFont::InterfaceDefault)); // Only show the attribution when there is actually art on screen: on a // cache miss cardArt is null and the pill would float over the plain @@ -394,6 +397,7 @@ UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, // Restyle the popup chrome when the theme or its color scheme changes. if (themeManager) { connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme); + connect(themeManager, &ThemeManager::fontsChanged, this, &UserInfoPopup::applyTheme); } } @@ -473,15 +477,24 @@ void UserInfoPopup::applyTheme() separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border))); - gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;") - .arg(colorStr(theme.subText))); + gamesLabel->setStyleSheet(QStringLiteral("color:%1; background:transparent;").arg(colorStr(theme.subText))); + QFont gamesLabelFont = themeManager->appFont(AppFont::InterfaceDefault); + gamesLabelFont.setPixelSize(11); + gamesLabelFont.setWeight(QFont::Bold); + gamesLabel->setFont(gamesLabelFont); - refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}" + refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;background:transparent;}" "QPushButton:hover{color:%2;}") .arg(colorStr(theme.subText), colorStr(theme.text))); + QFont refreshFont = themeManager->appFont(AppFont::InterfaceDefault); + refreshFont.setPixelSize(14); + refreshBtn->setFont(refreshFont); - gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;") - .arg(colorStr(theme.statusText))); + gamesStatus->setStyleSheet( + QStringLiteral("color:%1; padding:10px; background:transparent;").arg(colorStr(theme.statusText))); + QFont statusFont = themeManager->appFont(AppFont::InterfaceDefault); + statusFont.setPixelSize(11); + gamesStatus->setFont(statusFont); gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}" "QListView::item:selected{background:%2;}") @@ -489,9 +502,12 @@ void UserInfoPopup::applyTheme() closeBtn->setStyleSheet( QStringLiteral("QPushButton{background:%1;color:%2;" - "border:none;border-radius:11px;font-size:10px;}" + "border:none;border-radius:11px;}" "QPushButton:hover{color:%3;background:%4;}") .arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover))); + QFont closeFont = themeManager->appFont(AppFont::InterfaceDefault); + closeFont.setPixelSize(10); + closeBtn->setFont(closeFont); header->update(); } @@ -506,13 +522,17 @@ static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); b->setStyleSheet(QStringLiteral("QPushButton{" " background:%1;color:%2;border:1px solid %3;" - " border-radius:4px;font-size:11px;padding:0 4px;" + " border-radius:4px;padding:0 4px;" "}" "QPushButton:hover{background:%4;color:%5;}" "QPushButton:pressed{background:%6;}" "QPushButton:disabled{color:%7;border-color:%3;}") .arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover), colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled))); + // Family comes from the InterfaceDefault theme role; the 11px size stays caller-side. + QFont btnFont = themeManager->appFont(AppFont::InterfaceDefault); + btnFont.setPixelSize(11); + b->setFont(btnFont); return b; } diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 08eaaaff7..acb91351b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -1,6 +1,7 @@ #include "user_list_painter.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include @@ -292,7 +293,9 @@ void UserListPainter::drawUserName(QPainter *painter, bool online, const Style &style) { - QFont nameFont = option.font; + Q_UNUSED(option); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont nameFont = themeManager->appFont(AppFont::InterfaceDefault); nameFont.setBold(true); painter->setFont(nameFont); @@ -351,7 +354,9 @@ void UserListPainter::drawBadges(QPainter *painter, return; } - QFont badgeFont = option.font; + Q_UNUSED(option); + // Family comes from the InterfaceDefault theme role; the *0.68 size and bold stay caller-side. + QFont badgeFont = themeManager->appFont(AppFont::InterfaceDefault); badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.68); badgeFont.setBold(true); painter->setFont(badgeFont); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 3cb48c49e..f1de7b34f 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1499,7 +1499,7 @@ QTreeWidgetItem *UserListWidget::createSectionItem(Section section) // They act as collapsible section headers once they have focus. divider->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); - QFont font = userTree->font(); + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); divider->setFont(0, font); // A little taller than a plain text row so the header reads as a section diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp index bf35b7557..67354c9d7 100644 --- a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp @@ -5,6 +5,7 @@ */ #include "settings_search_delegate.h" +#include "../interface/theme_manager.h" #include "settings_search_model.h" #include @@ -88,7 +89,8 @@ void SettingsSearchDelegate::paint(QPainter *painter, QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0); // Breadcrumb: "Page > Group" - QFont breadcrumbFont = option.font; + // Family comes from the InterfaceDefault theme role; the -1 size and bold stay caller-side. + QFont breadcrumbFont = themeManager->appFont(AppFont::InterfaceDefault); breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1); breadcrumbFont.setBold(true); @@ -113,7 +115,7 @@ void SettingsSearchDelegate::paint(QPainter *painter, yPos += 20; // Setting label - QFont labelFont = option.font; + QFont labelFont = themeManager->appFont(AppFont::InterfaceDefault); labelFont.setPointSize(labelFont.pointSize() + 1); labelFont.setBold(isSelected); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp index b26a7e574..749476619 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp @@ -1,6 +1,9 @@ #include "edhrec_commander_api_response_bracket_navigation_widget.h" +#include "../../interface/theme_manager.h" + #include +#include #include #include #include @@ -25,7 +28,7 @@ EdhrecCommanderApiResponseBracketNavigationWidget::EdhrecCommanderApiResponseBra QString label = option.isEmpty() ? "All" : option.at(0).toUpper() + option.mid(1); QPushButton *optionButton = new QPushButton(label, this); optionButton->setMinimumHeight(84); - optionButton->setStyleSheet("font-size: 24px"); + optionButton->setStyleSheet(QString()); gameChangerButtons[option] = optionButton; layout->addWidget(optionButton, 2, i); connect(optionButton, &QPushButton::clicked, this, [=, this]() { @@ -35,6 +38,10 @@ EdhrecCommanderApiResponseBracketNavigationWidget::EdhrecCommanderApiResponseBra }); } + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseBracketNavigationWidget::applyInterfaceDefaultFont); + updateOptionButtonSelection(gameChangerButtons, ""); retranslateUi(); @@ -94,8 +101,18 @@ void EdhrecCommanderApiResponseBracketNavigationWidget::updateOptionButtonSelect const QString &selectedKey) { for (auto it = buttons.begin(); it != buttons.end(); ++it) { - it.value()->setStyleSheet(it.key() == selectedKey - ? "background-color: lightblue; font-weight: bold; font-size: 24px;" - : "font-size: 24px"); + const bool selected = (it.key() == selectedKey); + it.value()->setStyleSheet(selected ? QStringLiteral("background-color: lightblue;") : QString()); + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(24); + buttonFont.setWeight(selected ? QFont::Bold : QFont::Normal); + it.value()->setFont(buttonFont); } +} + +void EdhrecCommanderApiResponseBracketNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays + // caller-side and the selected button keeps its bold emphasis. + updateOptionButtonSelection(gameChangerButtons, selectedGameChanger); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h index a741b6696..aa4e61ff1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h @@ -37,6 +37,9 @@ private: QMap gameChangerButtons; void updateOptionButtonSelection(QMap &buttons, const QString &selectedKey); + +private slots: + void applyInterfaceDefaultFont(); }; #endif // COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BRACKET_NAVIGATION_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp index 48704d010..27cd1c51f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp @@ -1,6 +1,9 @@ #include "edhrec_commander_api_response_budget_navigation_widget.h" +#include "../../interface/theme_manager.h" + #include +#include #include #include #include @@ -25,7 +28,7 @@ EdhrecCommanderApiResponseBudgetNavigationWidget::EdhrecCommanderApiResponseBudg QString label = option.isEmpty() ? "Any" : option.at(0).toUpper() + option.mid(1); QPushButton *btn = new QPushButton(label, this); btn->setMinimumHeight(84); - btn->setStyleSheet("font-size: 24px"); + btn->setStyleSheet(QString()); budgetButtons[option] = btn; layout->addWidget(btn, 4, i); connect(btn, &QPushButton::clicked, this, [=, this]() { @@ -35,6 +38,10 @@ EdhrecCommanderApiResponseBudgetNavigationWidget::EdhrecCommanderApiResponseBudg }); } + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseBudgetNavigationWidget::applyInterfaceDefaultFont); + updateOptionButtonSelection(budgetButtons, ""); retranslateUi(); @@ -96,8 +103,18 @@ void EdhrecCommanderApiResponseBudgetNavigationWidget::updateOptionButtonSelecti const QString &selectedKey) { for (auto it = buttons.begin(); it != buttons.end(); ++it) { - it.value()->setStyleSheet(it.key() == selectedKey - ? "background-color: lightblue; font-weight: bold; font-size: 24px;" - : "font-size: 24px"); + const bool selected = (it.key() == selectedKey); + it.value()->setStyleSheet(selected ? QStringLiteral("background-color: lightblue;") : QString()); + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(24); + buttonFont.setWeight(selected ? QFont::Bold : QFont::Normal); + it.value()->setFont(buttonFont); } +} + +void EdhrecCommanderApiResponseBudgetNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays + // caller-side and the selected button keeps its bold emphasis. + updateOptionButtonSelection(budgetButtons, selectedBudget); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h index 8013d1d6b..d727225a5 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h @@ -37,6 +37,9 @@ private: QMap budgetButtons; void updateOptionButtonSelection(QMap &buttons, const QString &selectedKey); + +private slots: + void applyInterfaceDefaultFont(); }; #endif // COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BUDGET_NAVIGATION_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp index 220dcfbda..57070a5e1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp @@ -1,9 +1,11 @@ #include "edhrec_commander_api_response_navigation_widget.h" +#include "../../interface/theme_manager.h" #include "../../tab_edhrec_main.h" #include "edhrec_commander_api_response_bracket_navigation_widget.h" #include "edhrec_commander_api_response_budget_navigation_widget.h" +#include #include #include #include @@ -30,10 +32,14 @@ EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigation comboPushButton = new QPushButton(this); comboPushButton->setMinimumHeight(84); - comboPushButton->setStyleSheet("font-size: 24px"); + comboPushButton->setStyleSheet(QString()); averageDeckPushButton = new QPushButton(this); averageDeckPushButton->setMinimumHeight(84); - averageDeckPushButton->setStyleSheet("font-size: 24px"); + averageDeckPushButton->setStyleSheet(QString()); + + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseNavigationWidget::applyInterfaceDefaultFont); layout->addWidget(comboPushButton, 0, 0, 1, 1); layout->addWidget(averageDeckPushButton, 0, 1, 1, 1); @@ -61,6 +67,15 @@ void EdhrecCommanderApiResponseNavigationWidget::retranslateUi() averageDeckPushButton->setText(tr("Average Deck")); } +void EdhrecCommanderApiResponseNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays caller-side. + QFont navFont = themeManager->appFont(AppFont::InterfaceDefault); + navFont.setPixelSize(24); + comboPushButton->setFont(navFont); + averageDeckPushButton->setFont(navFont); +} + QString EdhrecCommanderApiResponseNavigationWidget::addNavigationOptionsToUrl(QString baseUrl) { if (!bracketNavigationWidget->getSelectedGameChanger().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h index 737558625..456228554 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h @@ -35,6 +35,9 @@ public slots: signals: void requestUrl(QString url); +private slots: + void applyInterfaceDefaultFont(); + private: QGridLayout *layout; diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp index d641a1ea5..acb9baefe 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -6,6 +6,7 @@ #include "tab_developer.h" +#include "../interface/theme_manager.h" #include "libcockatrice/protocol/pb/response.pb.h" #include @@ -155,7 +156,8 @@ void TabDeveloper::appendSeparatorRow(const QString §ionTitle) const int row = statsTable->rowCount(); statsTable->insertRow(row); auto *labelItem = new QTableWidgetItem(sectionTitle); - auto font = labelItem->font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); labelItem->setFont(font); labelItem->setFlags(labelItem->flags() & ~Qt::ItemIsSelectable); diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index ce37ce440..499f0d966 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -4,6 +4,7 @@ #include "../../deck_loader/deck_loader.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" +#include "../interface/theme_manager.h" #include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" #include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h" #include "../visual_deck_storage/remote_public_decks_model.h" @@ -45,7 +46,12 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); - titleLabel->setFont(QtUtils::boldFont(titleLabel->font())); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + auto applyTitleFont = [this]() { + titleLabel->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); + }; + applyTitleFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp index 76c3d0908..d3c087bc6 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp @@ -1,5 +1,8 @@ #include "visual_deck_editor_placeholder_widget.h" +#include "../interface/theme_manager.h" + +#include #include #include #include @@ -28,10 +31,12 @@ VisualDeckEditorPlaceholderWidget::VisualDeckEditorPlaceholderWidget(QWidget *pa textLabel->setStyleSheet(R"( QLabel { color: palette(mid); - font-size: 14px; padding: 10px; } )"); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &VisualDeckEditorPlaceholderWidget::applyInterfaceDefaultFont); mainLayout->addWidget(imageLabel); mainLayout->addWidget(textLabel); @@ -42,4 +47,12 @@ VisualDeckEditorPlaceholderWidget::VisualDeckEditorPlaceholderWidget(QWidget *pa void VisualDeckEditorPlaceholderWidget::retranslateUi() { textLabel->setText(tr("Add cards using the search bar or database tab to have them appear here")); +} + +void VisualDeckEditorPlaceholderWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 14px size stays caller-side. + QFont placeholderFont = themeManager->appFont(AppFont::InterfaceDefault); + placeholderFont.setPixelSize(14); + textLabel->setFont(placeholderFont); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h index 84a3142d8..794be671e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h @@ -15,6 +15,9 @@ public: explicit VisualDeckEditorPlaceholderWidget(QWidget *parent = nullptr); void retranslateUi(); +private slots: + void applyInterfaceDefaultFont(); + private: QVBoxLayout *mainLayout; QLabel *imageLabel; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp index 82ed6191c..cc809cc71 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp @@ -1,5 +1,7 @@ #include "deck_preview_tag_addition_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -65,8 +67,9 @@ void DeckPreviewTagAdditionWidget::paintEvent(QPaintEvent *event) painter.setPen(borderPen); painter.drawRect(rect().adjusted(0, 0, -1, -1)); // Adjust for pen width - // Calculate font size based on widget height - QFont font = painter.font(); + // Calculate font size based on widget height. Family comes from the + // InterfaceDefault theme role; the height-derived size stays as before. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); int fontSize = std::max(10, height() / 2); // Ensure a minimum font size of 10 font.setPointSize(fontSize); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index e62570241..e946f6e27 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -1,5 +1,7 @@ #include "deck_preview_tag_display_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -117,8 +119,9 @@ void DeckPreviewTagDisplayWidget::paintEvent(QPaintEvent *event) painter.setPen(borderPen); painter.drawRect(rect().adjusted(0, 0, -1, -1)); - // Calculate font size based on widget height - QFont font = painter.font(); + // Calculate font size based on widget height. Family comes from the + // InterfaceDefault theme role; the height-derived size stays as before. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); int fontSize = std::max(10, height() / 2); // Ensure a minimum font size of 10 font.setPointSize(fontSize); painter.setFont(font); From 13e7a3baaf6637ac0262547458a7d6416a6edb4f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:55 +0100 Subject: [PATCH 222/247] [Fonts] Fix font preview leaks and reuse default-font loader (#7540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../palette_editor/font_grid_widget.cpp | 4 ++ .../palette_editor/palette_editor_dialog.cpp | 48 ++++++++++++------- .../palette_editor/palette_editor_dialog.h | 7 ++- cockatrice/src/interface/theme_manager.cpp | 11 +---- 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp index 0f01fbe09..20451afbc 100644 --- a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -71,6 +71,9 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) void FontGridWidget::loadFonts(const FontConfig &cfg) { + // Suppress fontsChanged while materializing fallback rows so the initial + // load never reads as a user edit (mirrors paletteGrid blocking). + blockSignals(true); QMetaEnum fontEnum = QMetaEnum::fromType(); for (int i = 0; i < fontEnum.keyCount(); ++i) { auto role = static_cast(fontEnum.value(i)); @@ -93,6 +96,7 @@ void FontGridWidget::loadFonts(const FontConfig &cfg) } row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE)); } + blockSignals(false); } FontConfig FontGridWidget::currentFontConfig() const diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 09c81df5d..e1c9015d9 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -185,7 +185,10 @@ void PaletteEditorDialog::setupUi() connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply); connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent); connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); }); - connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { autoApplyTimer->start(); }); + connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { + fontsDirty = true; + autoApplyTimer->start(); + }); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); @@ -280,15 +283,9 @@ void PaletteEditorDialog::loadSchemes() void PaletteEditorDialog::loadFonts() { - // Same user-dir > system-dir fallback ThemeManager uses: the shipped - // fonts.toml may live in the system theme directory rather than the - // resolved (user) theme directory. - FontConfig cfg = ThemeManager::loadFontConfig(themeDirPath); - if (!cfg.hasFonts()) { - cfg = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); - } - savedFontConfig = cfg; - fontGrid->loadFonts(cfg); + savedFontConfig = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + fontGrid->loadFonts(savedFontConfig); + fontsDirty = false; } void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme) @@ -321,7 +318,9 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi void PaletteEditorDialog::onApply() { themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme); - themeManager->previewFonts(fontGrid->currentFontConfig()); + if (fontsDirty) { + themeManager->previewFonts(fontGrid->currentFontConfig()); + } } void PaletteEditorDialog::onSave() @@ -373,14 +372,17 @@ void PaletteEditorDialog::onSave() } // Persist the shared per-theme fonts when they changed. - const FontConfig fontCfg = fontGrid->currentFontConfig(); - if (fontCfg != savedFontConfig) { - if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) { - QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir)); - return; + if (fontsDirty) { + const FontConfig fontCfg = fontGrid->currentFontConfig(); + if (fontCfg != savedFontConfig) { + if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir)); + return; + } + savedFontConfig = fontCfg; } - savedFontConfig = fontCfg; + fontsDirty = false; } themeManager->reloadCurrentTheme(); @@ -396,6 +398,7 @@ void PaletteEditorDialog::onReset() // config until the theme is reloaded. themeManager->reloadCurrentTheme(); fontGrid->loadFonts(savedFontConfig); + fontsDirty = false; seedAccentFromScheme(loadedScheme); } @@ -442,6 +445,15 @@ void PaletteEditorDialog::onRevertToDefault() const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); savedFontConfig = fontDef; fontGrid->loadFonts(fontDef); + fontsDirty = false; +} + +void PaletteEditorDialog::reject() +{ + // Discard any unsaved live preview (palette and fonts) so closing without + // Save restores the active theme instead of leaking the preview globally. + themeManager->reloadCurrentTheme(); + QDialog::reject(); } void PaletteEditorDialog::changeEvent(QEvent *e) diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index 0a9d47c6c..5e47536b5 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -26,6 +26,7 @@ class PaletteEditorDialog : public QDialog public: explicit PaletteEditorDialog(const QString &themeDirPath, const QString &themeName, QWidget *parent = nullptr); void loadSchemes(); + void reject() override; private slots: void onSave(); @@ -79,6 +80,10 @@ private: // Fonts are shared per theme (fonts.toml, not split by light/dark), so a // single saved config suffices; the live edits are read from fontGrid. FontConfig savedFontConfig; + // Set only by user-driven fontsChanged after the initial load completes. + // Gates both previewFonts and the fonts.toml write so a palette-only Save + // never materializes fallback fonts to disk. + bool fontsDirty = false; QTimer *autoApplyTimer = nullptr; @@ -86,4 +91,4 @@ protected: void changeEvent(QEvent *e) override; }; -#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H \ No newline at end of file +#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index fdd6bbc30..d2580a18d 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -742,16 +742,7 @@ void ThemeManager::themeChangedSlot() applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); // ── Load fonts: shared per theme, no light/dark split ─────────────────── - FontConfig fonts = loadFontConfig(dirPath); - if (!fonts.hasFonts()) { - // The shipped fonts.toml may live in the system theme directory rather - // than the resolved (user) theme directory, so built-in themes still - // get their curated fonts. - const QString sysFontPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); - if (sysFontPath != dirPath) { - fonts = loadFontConfig(sysFontPath); - } - } + FontConfig fonts = loadDefaultFontConfig(dirPath, themeName); currentFonts = fonts; const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault); if (fontIt != currentFonts.fonts.constEnd()) { From a791389c6abc3c15c98969397159c49dbd5689c1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:55 +0100 Subject: [PATCH 223/247] [Fonts] Restore DeckList Serif default and live-update all role readers (#7541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Restore DeckList Serif default and live-update all role readers * [Fonts] Keep the DeckList fallback on the application font DeckList is read by the deck editor headers, the filter tree and the deck view too, not only by the two surfaces that hardcoded Serif. Resolving the unset role to Serif in appFont() made every user without a fonts.toml see Serif outside the deck list. - appFont(DeckList) falls back to the application font again. - ThemeManager::deckListFont() resolves the role and keeps the Serif fallback for the two historical sites: the deck view zone headers and the printed deck list. - DeckView no longer repaints its viewport on fontsChanged; touching the scene items already schedules the repaint in every attached view. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/shortcut_treeview.cpp | 12 +++++++++++ cockatrice/src/filters/filter_tree_model.cpp | 6 ++++++ .../src/game_graphics/deckview/deck_view.cpp | 15 ++++++++++---- cockatrice/src/game_graphics/game_scene.cpp | 9 +++++++++ .../src/interface/deck_loader/deck_loader.cpp | 14 +++++++------ cockatrice/src/interface/theme_manager.cpp | 20 +++++++++++++++---- cockatrice/src/interface/theme_manager.h | 10 ++++++++-- .../deck_editor/deck_list_style_proxy.cpp | 10 ++++++++++ .../deck_editor/deck_list_style_proxy.h | 2 +- .../widgets/dialogs/settings_view.cpp | 5 +++++ .../display/charts/bars/bar_chart_widget.cpp | 4 ++++ .../charts/bars/segmented_bar_widget.cpp | 4 ++++ .../widgets/server/user/user_list_widget.cpp | 13 ++++++++++++ .../interface/widgets/tabs/tab_developer.cpp | 14 +++++++++++++ .../widgets/tabs/tab_public_decks.cpp | 7 ++++++- .../widgets/utility/card_completer_styler.cpp | 5 +++++ .../deck_preview_tag_addition_widget.cpp | 4 ++++ .../deck_preview_tag_display_widget.cpp | 4 ++++ 18 files changed, 140 insertions(+), 18 deletions(-) diff --git a/cockatrice/src/client/settings/shortcut_treeview.cpp b/cockatrice/src/client/settings/shortcut_treeview.cpp index d81589f34..2501798cb 100644 --- a/cockatrice/src/client/settings/shortcut_treeview.cpp +++ b/cockatrice/src/client/settings/shortcut_treeview.cpp @@ -79,6 +79,18 @@ ShortcutTreeView::ShortcutTreeView(QWidget *parent) : QTreeView(parent) connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &ShortcutTreeView::refreshShortcuts); + + // Group headers store their font on the item; re-apply the role family on + // font changes (setFont emits itemChanged, which repaints the rows). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont groupFont = themeManager->appFont(AppFont::InterfaceDefault); + groupFont.setBold(true); + for (int row = 0; row < shortcutsModel->rowCount(); ++row) { + if (auto *groupItem = shortcutsModel->item(row)) { + groupItem->setFont(groupFont); + } + } + }); } void ShortcutTreeView::populateShortcutsModel() diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index 467c2564b..66fe39821 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -41,6 +41,12 @@ FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) connect(fTree, &FilterTree::postInsertRow, this, &FilterTreeModel::proxyEndInsertRow); connect(fTree, &FilterTree::preRemoveRow, this, &FilterTreeModel::proxyBeginRemoveRow); connect(fTree, &FilterTree::postRemoveRow, this, &FilterTreeModel::proxyEndRemoveRow); + // FontRole data reads the DeckList role live; rebroadcast so attached + // views re-query instead of waiting for an incidental repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + emit layoutAboutToBeChanged(); + emit layoutChanged(); + }); } FilterTreeModel::~FilterTreeModel() diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 63ee48e57..f62d33335 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -241,8 +241,7 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI painter->setPen(QColor(Qt::white)); // Family comes from the DeckList theme role; pixel sizes/weights unchanged. - QFont f = themeManager->appFont(AppFont::DeckList); - f.setStyleHint(QFont::Serif); + QFont f = themeManager->deckListFont(); f.setPixelSize(24); f.setWeight(QFont::Bold); painter->setFont(f); @@ -294,8 +293,7 @@ QList> DeckViewCardContainer::getRowsAndCols() const int DeckViewCardContainer::getCardTypeTextWidth() const { // Family comes from the DeckList theme role; pixel size/weight unchanged. - QFont f = themeManager->appFont(AppFont::DeckList); - f.setStyleHint(QFont::Serif); + QFont f = themeManager->deckListFont(); f.setPixelSize(16); f.setWeight(QFont::Bold); QFontMetrics fm(f); @@ -368,6 +366,15 @@ void DeckViewCardContainer::setWidth(qreal _width) DeckViewScene::DeckViewScene(QObject *parent) : QGraphicsScene(parent), locked(true), deck(0), optimalAspectRatio(1.0) { + // Zone headers read the DeckList role at paint time and cache their + // rendering, so touch every item to invalidate the caches and pick up the + // new family on font changes (containers cannot connect this themselves: + // QGraphicsItem is not a QObject). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + for (auto *item : items()) { + item->update(); + } + }); } DeckViewScene::~DeckViewScene() diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index db1040006..1f780b795 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -1,6 +1,7 @@ #include "game_scene.h" #include "../client/settings/cache_settings.h" +#include "../interface/theme_manager.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" @@ -64,6 +65,14 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) addItem(phasesToolbar); connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this, &GameScene::rearrange); + // CardText items (P/T boxes, counters, player names) read the role at + // paint time and cache their rendering, so touch every item to invalidate + // the caches and pick up the new family on font changes. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + for (auto *item : items()) { + item->update(); + } + }); rearrange(); } diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index d5c7de9a4..daa5dc98a 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -581,7 +581,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode QTextBlockFormat blockFormat; QTextCharFormat charFormat; // Family comes from the DeckList theme role; sizes/weights unchanged. - charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + charFormat.setFontFamilies({themeManager->deckListFont().family()}); charFormat.setFontPointSize(11); charFormat.setFontWeight(QFont::Bold); cursor->insertBlock(blockFormat, charFormat); @@ -595,7 +595,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; - cellCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + cellCharFormat.setFontFamilies({themeManager->deckListFont().family()}); cellCharFormat.setFontPointSize(9); QTextTableCell cell = table->cellAt(i, 0); @@ -617,7 +617,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode QTextBlockFormat blockFormat; QTextCharFormat charFormat; - charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + charFormat.setFontFamilies({themeManager->deckListFont().family()}); charFormat.setFontPointSize(14); charFormat.setFontWeight(QFont::Bold); @@ -646,15 +646,17 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList) QTextDocument doc; // Family comes from the DeckList theme role; the print sizes/weights below stay unchanged. - QFont font = themeManager->appFont(AppFont::DeckList); - font.setStyleHint(QFont::Serif); + // Print output is a one-shot snapshot and intentionally does not subscribe + // to fontsChanged (there is no live view to repaint); reprinting picks up + // the current role font. + QFont font = themeManager->deckListFont(); doc.setDefaultFont(font); QTextCursor cursor(&doc); QTextBlockFormat headerBlockFormat; QTextCharFormat headerCharFormat; - headerCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + headerCharFormat.setFontFamilies({themeManager->deckListFont().family()}); headerCharFormat.setFontPointSize(16); headerCharFormat.setFontWeight(QFont::Bold); diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index d2580a18d..f120af8e4 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -660,10 +660,12 @@ QFont ThemeManager::appFont(AppFont::Role role) const } // Role fallbacks preserve the pre-theme defaults when no fonts.toml is - // present: an unset role resolves to the application font, so the result is - // identical to before. The report dialogs that used to hardcode monospace - // ask for it explicitly (report_utils::reportLogFont), and the counter - // painters that used to hardcode Serif ask for it through cardTextFont(). + // present: an unset role resolves to the application font. Surfaces that + // hardcoded a different family ask for it explicitly: counter painters + // through cardTextFont(), the deck view headers and printed deck list + // through deckListFont(), the report dialogs through + // report_utils::reportLogFont(). Only the family basis is fixed there; + // sizes stay caller-side. return qApp->font(); } @@ -682,6 +684,16 @@ QFont ThemeManager::cardTextFont() const return serifFont; } +QFont ThemeManager::deckListFont() const +{ + if (hasAppFont(AppFont::DeckList)) { + return appFont(AppFont::DeckList); + } + QFont serifFont(QStringLiteral("Serif")); + serifFont.setStyleHint(QFont::Serif); + return serifFont; +} + QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const { // No per-role scaling and no preview: an unset role resolves to the font diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 35cc36d04..ffe1043b5 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -150,14 +150,20 @@ public: // when present, otherwise a palette-accent-derived fallback. QColor appColor(AppColor::Role role) const; /** @brief Resolves an application font role: the theme's stored [Fonts] - * value when present, otherwise a per-role fallback (serif basis - * for CardText, the application font for the remaining roles). */ + * value when present, otherwise the application font. Roles that + * had a historical hardcoded family ask for it through + * cardTextFont() and deckListFont() instead. */ QFont appFont(AppFont::Role role) const; /** @brief Whether the active theme defines this role in fonts.toml. */ bool hasAppFont(AppFont::Role role) const; /** @brief The CardText role with its historical Serif fallback. Used by the * counter painters, which were Serif before the role existed. */ QFont cardTextFont() const; + /** @brief The DeckList role with its historical Serif fallback. Used by the + * two surfaces that hardcoded Serif before the role existed (the + * deck view zone headers and the printed deck list); every other + * DeckList reader falls back to the application font. */ + QFont deckListFont() const; /** @brief Resolves an application font role against the startup font only, * ignoring any previewed config in currentFonts. Editors that show * "what this role resolves to" must use this, because appFont() diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp index 59b7fff79..a9c9cad7a 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp @@ -10,6 +10,16 @@ #include #include +DeckListStyleProxy::DeckListStyleProxy(QObject *parent) : QIdentityProxyModel(parent) +{ + // FontRole data reads the DeckList role live; rebroadcast so attached + // views re-query instead of waiting for an incidental repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + emit layoutAboutToBeChanged(); + emit layoutChanged(); + }); +} + QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const { QModelIndex src = mapToSource(index); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h index e4d1de4f4..3149da6de 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h @@ -8,7 +8,7 @@ class DeckListStyleProxy : public QIdentityProxyModel { Q_OBJECT public: - using QIdentityProxyModel::QIdentityProxyModel; + explicit DeckListStyleProxy(QObject *parent = nullptr); [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; }; diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 9c0f12979..005824995 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -7,6 +7,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../pixel_map_generator.h" +#include "../interface/theme_manager.h" #include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" @@ -203,6 +204,10 @@ void SettingsView::setupUi() searchDelegate->setPageIcons(icons); searchResultsView->viewport()->update(); }); + // The search delegate reads the InterfaceDefault role at paint time, so a + // font change only needs a repaint, mirroring the theme change above. + connect(themeManager, &ThemeManager::fontsChanged, searchResultsView, + [this] { searchResultsView->viewport()->update(); }); // Build search index after pages are created buildSearchIndex(); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index cd2a3702d..6bb1dcd62 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp @@ -21,6 +21,10 @@ BarChartWidget::BarChartWidget(QWidget *parent) : QWidget(parent) { setMouseTracking(true); + + // Labels read the InterfaceDefault role at paint time, so a font change + // only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } void BarChartWidget::setBars(const QVector &newBars) diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp index d6c75f3bd..359d56772 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp @@ -24,6 +24,10 @@ SegmentedBarWidget::SegmentedBarWidget(QString label, QVector segments, setMinimumWidth(36); setMaximumWidth(50); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + + // The label reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } QSize SegmentedBarWidget::sizeHint() const diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index f1de7b34f..943b4d5de 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -519,6 +519,19 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->viewport()->update(); userTree->update(); }); + // Divider headers store their font on the item, so re-apply the role + // family here; the painter reads the role live and needs only the + // repaint below. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont dividerFont = themeManager->appFont(AppFont::InterfaceDefault); + dividerFont.setBold(true); + for (auto it = sectionItems.constBegin(); it != sectionItems.constEnd(); ++it) { + it.value()->setFont(0, dividerFont); + it.value()->setSizeHint(0, QSize(0, QFontMetrics(dividerFont).height() + 16)); + } + userTree->viewport()->update(); + userTree->update(); + }); } QVBoxLayout *vbox = new QVBoxLayout; diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp index acb9baefe..024cd46f0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -101,6 +101,20 @@ TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_clien central->setLayout(mainLayout); setCentralWidget(central); + // Separator rows store their font on the item; re-apply the role family on + // font changes (value cells keep the default font and need only a repaint). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont separatorFont = themeManager->appFont(AppFont::InterfaceDefault); + separatorFont.setBold(true); + for (int row = 0; row < statsTable->rowCount(); ++row) { + auto *labelItem = statsTable->item(row, 0); + if (labelItem && !(labelItem->flags() & Qt::ItemIsSelectable)) { + labelItem->setFont(separatorFont); + } + } + statsTable->viewport()->update(); + }); + retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index 499f0d966..dc14b6e4a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -51,7 +51,12 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c titleLabel->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); }; applyTitleFont(); - connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont); + connect(themeManager, &ThemeManager::fontsChanged, this, [this, applyTitleFont] { + applyTitleFont(); + // Preview cells paint with the live role font; repaint the grid so the + // new family shows without waiting for a scroll or hover. + flowWidget->update(); + }); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 8f1941412..1a17bb8be 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -1,5 +1,6 @@ #include "card_completer_styler.h" +#include "../../interface/theme_manager.h" #include "../cards/card_info_picture_enlarged_widget.h" #include "card_completer_delegate.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -73,6 +74,10 @@ CardCompleterStyler::CardCompleterStyler(QCompleter *completer, QObject *parent) popup->viewport()->setMouseTracking(true); + // The delegate reads the CardText role at paint time, so a font change + // only needs a repaint of the popup. + connect(themeManager, &ThemeManager::fontsChanged, popup, [popup] { popup->viewport()->update(); }); + popup->installEventFilter(this); popup->viewport()->installEventFilter(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp index cc809cc71..30e99f401 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp @@ -28,6 +28,10 @@ DeckPreviewTagAdditionWidget::DeckPreviewTagAdditionWidget(QWidget *_parent, QSt // Adjust widget size setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + // The tag text reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } QSize DeckPreviewTagAdditionWidget::sizeHint() const diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index e946f6e27..34809da2e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -43,6 +43,10 @@ DeckPreviewTagDisplayWidget::DeckPreviewTagDisplayWidget(QWidget *parent, const // Adjust widget size setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + // The tag text reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); + // Connect close button to the remove signal // connect(closeButton, &QPushButton::clicked, this, &DeckPreviewTagDisplayWidget::tagClosed); } From ed119cc940dda57188cddda856e2850140e58f9c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:55 +0100 Subject: [PATCH 224/247] [Fonts] Polish font editor accessibility and note text (#7542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/interface/palette_editor/font_grid_widget.cpp | 7 ++++++- .../src/interface/palette_editor/palette_editor_dialog.cpp | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp index 20451afbc..44ad59551 100644 --- a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -63,6 +63,8 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) grid->addWidget(sizeSpin, i, 2); connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); + label->setBuddy(familyCombo); + fontRows[role] = {label, familyCombo, sizeSpin}; } @@ -126,6 +128,9 @@ void FontGridWidget::retranslateUi() row.label->setText(roleDisplayName(role)); row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); - row.sizeSpin->setToolTip(tr("Point size")); + row.familyCombo->setAccessibleName(roleDisplayName(role)); + row.sizeSpin->setToolTip(tr("Point size for %1").arg(roleDisplayName(role))); + row.sizeSpin->setAccessibleName(tr("Point size for %1").arg(roleDisplayName(role))); + row.sizeSpin->setSuffix(tr(" pt")); } } diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index e1c9015d9..5bcfbb251 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -147,6 +147,7 @@ void PaletteEditorDialog::setupUi() fontSectionLayout->setSpacing(0); fontNoteLabel = new QLabel(fontSection); fontNoteLabel->setContentsMargins(12, 4, 12, 0); + fontNoteLabel->setWordWrap(true); fontSectionLayout->addWidget(fontNoteLabel); fontGrid = new FontGridWidget(fontSection); fontSectionLayout->addWidget(fontGrid); @@ -240,7 +241,7 @@ void PaletteEditorDialog::retranslateUi() paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks")); fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme")); - fontNoteLabel->setText(tr("Fonts belong to theme %1").arg(themeName)); + fontNoteLabel->setText(tr("Fonts belong to theme %1 (shared by Light and Dark schemes)").arg(themeName)); fontGrid->retranslateUi(); revertButton->setText(tr("↺ Revert to theme default")); From 7ca697348b68ee60c6642f25687bc7399f4df3cc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:06:56 +0100 Subject: [PATCH 225/247] [CI] Give ccache room to hold a configuration (#7667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Re-assert the ccache timestamp sloppiness after configure CMakeLists.txt sets sloppiness=pch_defines,time_macros at configure time, and ccache --set-config replaces the whole list rather than extending it. Setting the four entries before cmake would therefore leave the build with only two, so the entries CI needs have to be written after the configure step and before the build. The mtime and ctime entries cover files created during the build itself: moc/uic/rcc output and protobuf headers are generated after ccache starts, so without them every TU that includes one is read as a race and never cached. Checked-out sources are older than the compile that follows them and do not trip the check. * [Build] Keep risky ccache sloppiness out of the global config USE_CCACHE defaults to ON, so CMakeLists.txt runs on a developer machine, and ccache --set-config writes the user's global ccache.conf. The include_file_mtime/include_file_ctime entries turn off the modified-during-compile check for every project on the machine, not only this one, so they belong in CI (compile.sh) and not here. The two entries PCH caching needs stay. * [CI] Size each compiler cache against the cache quota The 600M bound sat below what a leg writes: ccache spent each build deleting the entries the next run asked for, with 2624-3720 cleanup passes and about 8% of calls served in the runs inspected on 2026-11-04. Raising it needs room first. The repository's 10 GB quota currently holds 1.1 GB of CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers. Two of those are now cheap to reclaim: - docker-release wrote its layer cache with mode=max, but the build stage sits behind COPY . ., so its compile layer is invalidated by every source change and was never read back. mode=min keeps only the final image stage. - the five nightly-only legs kept their own compiler caches, 600M each, that no pull request ever reads. They now keep none, and the prune job retires their leftover keys immediately instead of waiting for GitHub's whole-entry LRU eviction to pick them. That funds per-leg bounds: 700M for one configuration, 850M for the Debian 13 release that also runs the suite, 900M for Fedora 44's Debug and Release, 800M for the instrumented leg, 400M for the server-only leg, and 700M for each macOS leg. 6.45G of ccache plus the 3.2G of fixed entries leaves about 350M under the quota. The prune step also covers the buildkit entries by age now, so the mode=max generation of layer blobs is reclaimed instead of living forever: a layer a build needs is downloaded and its date refreshed, and dropping one that an index still lists only costs docker-release a rebuilt layer, which gates nothing. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 18 ++++- .github/workflows/desktop-build.yml | 105 ++++++++++++++++++++++----- .github/workflows/docker-release.yml | 10 ++- CMakeLists.txt | 4 + 4 files changed, 114 insertions(+), 23 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 1db0cacd1..569ae9f6b 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -185,9 +185,6 @@ if [[ $MAKE_COVERAGE ]]; then fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") - # PCH-aware caching is required or ccache refuses to cache any TU that - # consumes a precompiled header, silently recompiling everything on every run. - ccache --set-config sloppiness=pch_defines,time_macros if [[ $CCACHE_SIZE ]]; then # note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" @@ -378,6 +375,21 @@ echo "Running cmake with flags: ${flags[*]}" cmake .. "${flags[@]}" echo "::endgroup::" +if [[ $USE_CCACHE ]]; then + # PCH-aware caching is required or ccache refuses to cache any TU that consumes a + # precompiled header, silently recompiling everything on every run. The mtime and ctime + # entries cover files created during the build itself: the moc/uic/rcc output and the + # protobuf headers are generated after ccache starts, so without them every TU including + # one is read as a race and never cached. Checked-out sources are older than the compile + # that follows them and do not trip the check. + # + # This has to run after configure: CMakeLists.txt sets sloppiness=pch_defines,time_macros + # at configure time, and --set-config replaces the whole list rather than extending it, so + # setting it earlier would leave the build below with two entries. Re-asserting all four + # here is what actually reaches the compiler; the order is the point. + ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime +fi + echo "::group::Build project" cpuinfo echo "Running cmake --build with flags: ${buildflags[*]}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index d72cb881e..be11c3748 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -157,11 +157,13 @@ jobs: package: DEB server_only: yes test: skip + ccache_size: 400M # server only, no client to compile - distro: Debian version: 13 package: DEB + ccache_size: 850M # the Release build plus the suite it runs - distro: Fedora version: 44 @@ -172,6 +174,7 @@ jobs: # only the Debug configuration passes -Werror, so these two are the legs where a # new warning in either compiler still fails the build. debug_test: yes + ccache_size: 900M # Debug for the suite and Release for the package - distro: Ubuntu version: 26.04 @@ -193,6 +196,7 @@ jobs: package: skip sanitize: yes + ccache_size: 800M # instrumented objects are larger name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure @@ -205,7 +209,27 @@ jobs: env: CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # The bound is sized against the repository's 10 GB cache quota + # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy), + # not against what one leg would like. The quota currently holds about 1.1 GB of + # CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers + # (mode=min in docker-release.yml, added here, brings the last one down to roughly + # 0.3 GB - the one input here that is an expectation rather than a measurement, since + # mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five + # nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what + # makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache + # bounds + 3.2G of the fixed entries = 9.65G against the 10G quota. + # + # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat + # below what a leg writes: ccache spent each build deleting the entries the next run + # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each + # leg states the size it needs instead: one configuration for most, two for Fedora 44 + # (Debug for the suite, Release for the package), the server alone for Servatrice. + # Raising these further means caching fewer legs, since GitHub evicts a whole entry + # when the total is exceeded. + # + # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -218,6 +242,11 @@ jobs: # also package, so testing and packaging cost one compile instead of two. DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} + # Whether this leg restores and saves its compiler cache. Every leg a pull request + # runs keeps one; the nightly-only legs do not, so that the quota is spent on the + # builds that review a change. A tag build runs both sets, and the nightly legs are + # cold there - accepted, because a tag is rare and only the pull-request legs gate. + PERSIST_CCACHE: 'true' # Shared with build-linux-nightly so the two leg sets run the same steps. steps: &linux-steps @@ -226,6 +255,7 @@ jobs: - name: "Restore compiler cache (ccache)" id: restore_ccache + if: env.PERSIST_CCACHE == 'true' uses: actions/cache/restore@v6 with: key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} @@ -371,7 +401,7 @@ jobs: # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" - if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -382,7 +412,7 @@ jobs: fi - name: "Save updated compiler cache (ccache)" - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -479,7 +509,12 @@ jobs: env: CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # These legs do not keep a compiler cache: they run once a day (and on tags) and gate + # nothing, so their share of the 10 GB quota goes to the pull-request legs instead. + # A cold nightly build costs minutes on a runner nobody waits for; a cold pull request + # build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir. + PERSIST_CCACHE: 'false' + CCACHE_SIZE: 600M CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -571,7 +606,13 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # macOS configurations compile about twice the TUs of a Linux one, so each of these + # three legs gets a bound above what it has been measured to write. The four macOS + # legs used to be bounded at 600M from the same quota as the Linux ones; the three + # pull-request legs now get 700M each while the nightly Intel leg keeps none, and + # that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic). + CCACHE_SIZE: 700M + PERSIST_CCACHE: 'true' # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps @@ -591,7 +632,7 @@ jobs: run: brew install ccache - name: "[macOS] Restore compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' id: restore_ccache uses: actions/cache/restore@v6 with: @@ -922,7 +963,7 @@ jobs: # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "[macOS] Delete remote compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -933,7 +974,7 @@ jobs: fi - name: "[macOS] Cache updated compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -1082,7 +1123,10 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with + # the pull-request legs (see PERSIST_CCACHE there). + PERSIST_CCACHE: 'false' + CCACHE_SIZE: 600M steps: *vcpkg-steps prune-caches: @@ -1093,12 +1137,23 @@ jobs: continue-on-error: true # freeing space must never fail a build steps: - # The repository holds 10Gi of cache against entries that want more, and GitHub - # evicts the least recently used entry whenever that is exceeded. The vcpkg - # entries are the reason that never happens: the restore above touches every - # entry under the prefix on every run, so a dead ABI generation keeps its access - # date fresh forever and age based eviction can never reach it. Deleting by age - # alone cannot fix that, only knowing which scope an entry belongs to can. + # The repository holds 10 GB of cache against entries that want more, and GitHub evicts + # the least recently used entry whenever that is exceeded. The vcpkg entries are the + # reason that never happens: the restore above touches every entry under the prefix on + # every run, so a dead ABI generation keeps its access date fresh forever and age based + # eviction can never reach it. Deleting by age alone cannot fix that, only knowing which + # scope an entry belongs to can. The same is true of a compiler cache of a leg that no + # longer keeps one: its restore would keep touching it, so the live key list below is + # what retires it. + # + # docker-release's image builds keep their layer cache in the same quota: one blob per + # layer, keyed buildkit-blob-1-, plus one index--1- entry per image + # scope that lists them. They are written with mode=min, so only the final image stage + # is kept and the set stays small. A layer a build actually needs is downloaded, which + # refreshes its access date, so an old date means the builds have stopped using it and + # age is the right test. Dropping a blob an index still lists costs the next image build + # one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the + # entries forever. - name: "Delete unusable cache entries" shell: bash env: @@ -1107,25 +1162,39 @@ jobs: # matrices above, or the entries of a scope that is still in use get deleted # on the next run. LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' + # Must list the ccache key of every leg that keeps a compiler cache + # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. + # The nightly legs keep none, so their leftover entries are reclaimed here + # instead of waiting out the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macos-26_14-Xcode26.6 ccache-macos-26_15-Xcode26.6 ccache-macos-26_-Xcode26.6' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))" live=" ${LIVE_VCPKG_SCOPES} " + live_ccache=" ${LIVE_CCACHE_KEYS} " + deleted=0 while IFS=$'\t' read -r id ref key accessed; do [[ -n $id ]] || continue - # Only ever touch the key shapes this workflow creates, so that an entry of - # some other workflow can never be deleted by accident + # Only ever touch the key shapes our workflows create (buildkit-* and index-* + # come from docker-release.yml), so that an entry of some other workflow can + # never be deleted by accident case "$key" in - vcpkg/* | ccache-* | Qt*) ;; + vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;; *) continue ;; esac reason="" if [[ $ref != refs/heads/master ]]; then reason="scoped to $ref" + elif [[ $key == ccache-* ]]; then + # An exact token compare: ccache-Ubuntu26.04 is a prefix of + # ccache-Ubuntu26.04sanitize, and both keys are live + if [[ $live_ccache != *" $key "* ]]; then + reason="no pull-request leg keeps it" + fi elif [[ $key == vcpkg/* ]]; then # "/", cut the hash off the end to get the scope scope="vcpkg/${key#vcpkg/}" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 937386940..1537d6d4d 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -84,12 +84,18 @@ jobs: password: ${{ github.token }} # Don't push for non-publishing triggers + # + # The layer cache shares the repository's 10 GB Actions cache quota with the compiler + # caches (see CCACHE_SIZE in desktop-build.yml). mode=min keeps only the final image + # stage: the build stage sits behind `COPY . .`, so its compile layer is invalidated by + # every source change and was never read back, yet it was the bulk of the ~2 GB this + # cache held. The final runtime stage is the part that still pays off. - name: "Build image" if: steps.login.outcome != 'success' uses: docker/build-push-action@v7 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }} + cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }} context: . platforms: ${{ matrix.platform }} push: false @@ -101,7 +107,7 @@ jobs: uses: docker/build-push-action@v7 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }} context: . labels: | org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 563f174e8..9044bc005 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,10 @@ if(USE_CCACHE AND NOT WIN32) # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses # to cache any TU that consumes a precompiled header, so every PCH-backed # target recompiles from scratch on each build. + # The include_file_mtime/include_file_ctime entries that CI also sets are + # deliberately not set here: --set-config writes the user's global + # ccache.conf, and those two would turn off the modified-during-compile + # check for every project on the machine, not just this one. execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") endif() From 67330ead5a58e90de1515a4603ce58ff999dbb62 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 09:27:44 -0500 Subject: [PATCH 226/247] Embed bundled resources without compiling large C++ arrays [skip ci] (#7730) --- cockatrice/CMakeLists.txt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9a3172aa8..e437f4d56 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -495,7 +495,15 @@ if(APPLE) set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +# The fonts and card artwork produce a large C++ byte array with qt_add_resources. +# Embed the data in the compiled resource object instead, keeping compiler memory +# independent of that payload. Qt's two-pass resource build is unsupported on iOS. +if(IOS) + qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +else() + qt_add_big_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +endif() +set_source_files_properties(${cockatrice_RESOURCES} PROPERTIES SKIP_AUTORCC ON) # Declare path variables set(ICONDIR @@ -521,6 +529,7 @@ qt_add_executable( WIN32 MACOSX_BUNDLE ${cockatrice_SOURCES} + ${cockatrice_RESOURCES} ${cockatrice_RESOURCES_RCC} ${cockatrice_MOC_SRCS} MANUAL_FINALIZATION From 2056043104a83d5bf9e8a45a5d03320e7cc69914 Mon Sep 17 00:00:00 2001 From: tooomm Date: Wed, 4 Nov 2026 15:35:45 +0100 Subject: [PATCH 227/247] [CI] Update macOS runner (keep same compile targets) (#7331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update/add eol dates * use new macOS images * [CI] Retire the old macOS ccache keys from the live list prune-caches deletes any ccache-* entry whose key is not in LIVE_CCACHE_KEYS, so the three keys this PR renames would be dropped on the next master run and every macOS leg would start cold. Co-authored-by: Lukas Brübach --------- Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index be11c3748..4ac28d3a5 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -526,8 +526,8 @@ jobs: matrix: include: - os: macOS - target: 14 # EOL 2026-?? - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 14 # EOL 2026-09-14 + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja @@ -542,11 +542,10 @@ jobs: vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over - xcode: "26.6" - os: macOS - target: 15 - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 15 # EOL 2027-?? + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja @@ -560,11 +559,10 @@ jobs: use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' - xcode: "26.6" - os: macOS - target: 26 - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 27 + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja @@ -575,7 +573,6 @@ jobs: use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' - xcode: "26.6" - os: Windows target: 10 @@ -636,7 +633,7 @@ jobs: id: restore_ccache uses: actions/cache/restore@v6 with: - key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} + key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }} path: ${{ env.CCACHE_DIR }} - name: "[macOS] Install aqtinstall" @@ -751,7 +748,6 @@ jobs: # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN # and the symbol upload below just like full releases. COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }} - DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} @@ -1112,7 +1108,6 @@ jobs: use_ccache: 1 vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over - xcode: "26.3" name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} needs: configure @@ -1166,7 +1161,7 @@ jobs: # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. # The nightly legs keep none, so their leftover entries are reclaimed here # instead of waiting out the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macos-26_14-Xcode26.6 ccache-macos-26_15-Xcode26.6 ccache-macos-26_-Xcode26.6' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" From ca2301bc96cf0851d2292fb8927ca44ba2b9473a Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 09:43:38 -0500 Subject: [PATCH 228/247] Fix deck export and mana group ordering (#7729) --- .../src/interface/deck_loader/deck_loader.cpp | 9 ++++-- .../cards/deck_card_zone_display_widget.cpp | 4 +++ .../libcockatrice/deck_list/deck_list.cpp | 12 ++++++- .../models/deck_list/deck_list_model.cpp | 6 ++-- .../deck_list_model_custom_zones.cpp | 30 ++++++++++++++++-- .../deck_list/deck_list_model_custom_zones.h | 9 ++++-- .../deck_list_model_custom_zones_test.cpp | 31 +++++++++++++++++++ .../deck_list_zones/deck_list_zones_test.cpp | 22 +++++++++++++ 8 files changed, 113 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index daa5dc98a..c8fa2a6f8 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -372,8 +373,12 @@ bool DeckLoader::saveToStream_Plain(QTextStream &out, saveToStream_DeckHeader(out, deckList); } - // loop zones - for (auto zoneNode : deckList.getZoneNodes()) { + // Plain deck readers expect the main deck before the sideboard. Reorder a + // copy so native saves and the relative order of other zones stay intact. + auto zones = deckList.getZoneNodes(); + std::stable_partition(zones.begin(), zones.end(), + [](const auto *zone) { return zone->getName() == DECK_ZONE_MAIN; }); + for (auto zoneNode : zones) { saveToStream_DeckZone(out, zoneNode, addComments, addSetNameAndNumber); // end of zone diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index d7eef6c18..3ec2053b1 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -69,6 +69,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, updateZoneCardCount(); connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition); + connect(deckListModel, &QAbstractItemModel::layoutChanged, this, &DeckCardZoneDisplayWidget::displayCards); if (selectionModel) { connect(selectionModel, &QItemSelectionModel::selectionChanged, this, &DeckCardZoneDisplayWidget::onSelectionChanged); @@ -174,6 +175,9 @@ void DeckCardZoneDisplayWidget::displayCards() for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); + // Reuse existing widgets when a model sort moves their persistent rows. + // This preserves collapsed groups and card selection during live edits. + cardGroupLayout->insertWidget(persistent.row(), indexToWidgetMap.value(persistent)); } } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 2cd5ef914..b357b5801 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -340,7 +340,17 @@ bool DeckList::saveToStream_Plain(QTextStream &stream, bool prefixSideboardCards } }; - forEachCard(writeToStream); + // Export the main deck first without changing the saved tree's zone order. + forEachCard([&](const auto node, const auto card) { + if (node->getName() == DECK_ZONE_MAIN) { + writeToStream(node, card); + } + }); + forEachCard([&](const auto node, const auto card) { + if (node->getName() != DECK_ZONE_MAIN) { + writeToStream(node, card); + } + }); return true; } diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 617324fe3..f3c4545d7 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -746,7 +746,8 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) { // The mapping is not needed: fresh shadow nodes have no persistent indices yet. - (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + (void)DeckListModelCustomZones::sortWithCustomZonesLast( + root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0); for (int i = node->size() - 1; i >= 0; --i) { if (auto *subNode = dynamic_cast(node->at(i))) { @@ -759,7 +760,8 @@ void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) { // Sort children (custom zones always sorted after groups within a board) and // use the movement mapping to update the list of persistent indices. - const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast( + root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0); QModelIndexList from, to; int columns = columnCount(); diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp index 419c38b04..523708cbd 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include namespace DeckListModelCustomZones @@ -111,7 +113,7 @@ QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) * * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. */ -QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups) { const QVector> sortResult = node->sort(order); @@ -132,6 +134,27 @@ QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) } } + if (numericGroups) { + // Only criteria groups are numeric. Mixing numeric and lexical fallback + // comparisons with custom zone names would not define a strict ordering. + std::sort(groups.begin(), groups.end(), [order](const auto *left, const auto *right) { + const QString leftName = left->getName(); + const QString rightName = right->getName(); + bool leftIsNumber, rightIsNumber; + const double leftValue = leftName.toDouble(&leftIsNumber); + const double rightValue = rightName.toDouble(&rightIsNumber); + leftIsNumber = leftIsNumber && std::isfinite(leftValue); + rightIsNumber = rightIsNumber && std::isfinite(rightValue); + if (leftIsNumber != rightIsNumber) { + return leftIsNumber; + } + if (leftIsNumber && leftValue != rightValue) { + return order == Qt::AscendingOrder ? leftValue < rightValue : leftValue > rightValue; + } + return order == Qt::AscendingOrder ? leftName < rightName : leftName > rightName; + }); + } + QVector ordered = groups + customZones; for (int i = 0; i < ordered.size(); ++i) { node->replace(i, ordered[i]); @@ -147,10 +170,11 @@ QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) } // namespace -QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) +QList> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups) { const bool isBoardZone = (node != root) && (node->getParent() == root); - return isBoardZone ? boardSort(node, order) : plainSort(node, order); + return isBoardZone ? boardSort(node, order, numericGroups) : plainSort(node, order); } } // namespace DeckListModelCustomZones diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h index 53bdeaa1e..d3f477450 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -91,10 +91,15 @@ void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode * @param root Root of the shadow tree (used to classify board zones). * @param node The shadow node whose children are reordered. * @param order Sort order to apply. + * @param numericGroups Compare criteria-group names as mana values. Custom zone + * names and card names retain their normal comparison; nonnumeric groups follow + * numeric groups in either sort direction. * @return A list of (preSortRow, finalRow) pairs describing how each node moved. */ -[[nodiscard]] QList> -sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); +[[nodiscard]] QList> sortWithCustomZonesLast(InnerDecklistNode *root, + InnerDecklistNode *node, + Qt::SortOrder order, + bool numericGroups = false); } // namespace DeckListModelCustomZones diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp index 95df74702..d4e44125c 100644 --- a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -278,6 +278,37 @@ TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) EXPECT_EQ(mapping[1].second, 1); } +TEST(DeckListModelCustomZones, ManaGroupsUseNumericOrderWithoutChangingOtherNodes) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + const QStringList original = {"10", "2", "0.5", "1000000", "1", "unknown", "nan"}; + for (const auto &name : original) { + new InnerDecklistNode(name, board); + } + auto *custom = new DecklistModelSubZoneNode("1x", board); + new InnerDecklistNode("10", custom); + new InnerDecklistNode("2", custom); + root.setSortMethod(DeckSortMethod::ByName); + + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder, true); + EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "2", "10", "1000000", "nan", "unknown", "1x"})); + QStringList before = original; + before.append("1x"); + for (const auto &move : mapping) { + EXPECT_EQ(board->at(move.second)->getName(), before.at(move.first)); + } + + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder, true); + EXPECT_EQ(childNames(board), (QStringList{"1000000", "10", "2", "1", "0.5", "unknown", "nan", "1x"})); + + // The numeric flag only affects groups directly under a board. + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, custom, Qt::AscendingOrder, true); + EXPECT_EQ(childNames(custom), (QStringList{"10", "2"})); + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "10", "1000000", "2", "nan", "unknown", "1x"})); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp index 1903b70df..a67e689cf 100644 --- a/tests/deck_list_zones/deck_list_zones_test.cpp +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -393,6 +393,28 @@ TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash) EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash()); } +TEST(DeckListZones, PlainExportPutsMainFirstWithoutReorderingSavedZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Side Card", 2, "Side Tech", -1); + tree->addCard("Token Card", 1, DECK_ZONE_TOKENS, -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Main Card", 3, "Removal", -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Maybe Card", 4, "Candidates", -1); + + const QString native = deck.writeToString_Native(); + EXPECT_EQ(deck.writeToString_Plain(true, false), QString("3 Main Card\nSB: 2 Side Card\n1 Token Card\n")); + EXPECT_EQ(deck.writeToString_Plain(false, false), QString("3 Main Card\n2 Side Card\n1 Token Card\n")); + EXPECT_EQ(deck.writeToString_Native(), native); + + DeckList reopened(native); + EXPECT_EQ(reopened.getZoneNodes().first()->getName(), DECK_ZONE_SIDE); + EXPECT_EQ(reopened.writeToString_Plain(true, false), deck.writeToString_Plain(true, false)); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); From 5ea16c805f8c4ace3b59c47d19658f13032eea29 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:44:50 +0100 Subject: [PATCH 229/247] [Server/Client/Protocol] Shuffle or reorder player seats before a game starts (#7398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Protocol] Add shuffle players option and player order command/event Adds a 'Shuffle players at start' option to game creation plus a local player id on the game state so clients can reconcile seat changes, and a SET_PLAYER_ORDER command (1206) with a PLAYER_ORDER_CHANGED event (2023) so the game host can set or randomize the seating order. Took 6 minutes * [Server] Shuffle and reorder player seats before a game starts Auto-shuffles the seats at game start when enabled (now for games with two or more players instead of three or more) and handles the new SET_PLAYER_ORDER command so the game host or a moderator can reorder or randomize the seats before the game starts. Re-seating re-keys the player ids while keeping the per-user game/seat routing and persistent player records in sync, then broadcasts the new order to everyone. * [Client] Add shuffle players option and turn order dialog Adds the 'Shuffle players at start' checkbox to game creation and a host-only 'Set turn order...' action that opens a dialog to reorder or randomize the seats before the game starts. Seating changes are shown in the game's message log. * [Client] Move the turn order dialog strings into retranslateUi Follows the DlgInviteToGame convention so the dialog can be retranslated instead of freezing its strings at construction time. * [Server] Keep player ids stable across seat shuffles * [Client] Lay out table and turn order dialog in seat order * [Server] Send game-joined player list in seat order * [Client] Reword the shuffle option to match its behaviour * [Client] Address the seat-order review comments - Give the game setup checkboxes a row of their own so they no longer collide in the create-game dialog - Rename DlgTurnOrder::randomize() to isRandomizeRequested() - Document what the ids in GameState::seatOrder mean - Keep "Set turn order..." in sync with the game: hidden for non-hosts and once the game has started, re-shown when the game ends or hosting moves to the local player (new GameState::hostChanged signal) * [Client] Do not preview a randomized turn order Randomizing is a request to the server, not a local shuffle. Shuffling the list locally meant the dialog showed an order the server then discarded, and let the host re-open it until a favourable order appeared. The dialog now switches to a mode that says the server will randomize and toggles back to the manual list on request. * [Client] Let moderators set the turn order The server accepts SET_PLAYER_ORDER from the host or a moderator, but the client hid the action from everyone except the host, making the moderator branch unreachable. Visibility and the handler now share canSetTurnOrder(). * [Server] Rely on the player order event instead of a full state resend cmdSetPlayerOrder re-sent the whole game state after reseating, so every client reloaded its deck view mid-lobby for no reason. The new seating reaches clients through the Event_PlayerOrderChanged that reseatPlayers broadcasts. * [Client] Show random turn order in the game selector The restrictions column listed "open decklists" but not the new seating option, so a joining player had no way to see that seats will be randomized each game. Game info already shows the disabled checkbox. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/game_graphics/game_scene.cpp | 32 +++ .../game_graphics/log/message_log_widget.cpp | 14 + .../game_graphics/log/message_log_widget.h | 1 + .../widgets/dialogs/dlg_create_game.cpp | 13 +- .../widgets/dialogs/dlg_create_game.h | 1 + .../widgets/dialogs/dlg_turn_order.cpp | 136 +++++++++ .../widgets/dialogs/dlg_turn_order.h | 51 ++++ .../interface/widgets/server/games_model.cpp | 3 + .../src/interface/widgets/tabs/tab_game.cpp | 91 ++++++ .../src/interface/widgets/tabs/tab_game.h | 6 +- .../libcockatrice/game/game_event_handler.cpp | 77 +++++ .../libcockatrice/game/game_event_handler.h | 16 ++ .../libcockatrice/game/game_state.h | 22 ++ .../game/player/player_manager.h | 5 + .../interface_game_settings_provider.h | 1 + .../network/server/remote/game/game_config.h | 1 + .../game/server_abstract_participant.cpp | 31 ++ .../remote/game/server_abstract_participant.h | 7 + .../server/remote/game/server_game.cpp | 199 ++++++++++++- .../network/server/remote/game/server_game.h | 18 ++ .../server/remote/server_protocolhandler.cpp | 4 +- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 + .../pb/command_set_player_order.proto | 15 + .../pb/event_game_state_changed.proto | 5 + .../pb/event_player_order_changed.proto | 13 + .../protocol/pb/game_commands.proto | 5 + .../protocol/pb/game_event.proto | 1 + .../protocol/pb/room_commands.proto | 3 + .../protocol/pb/serverinfo_game.proto | 3 + .../libcockatrice/settings/game_settings.cpp | 10 + .../libcockatrice/settings/game_settings.h | 2 + tests/movecard_tests/CMakeLists.txt | 12 + tests/movecard_tests/player_order_test.cpp | 268 ++++++++++++++++++ 34 files changed, 1054 insertions(+), 15 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto create mode 100644 tests/movecard_tests/player_order_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e437f4d56..b18846079 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -56,6 +56,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp + src/interface/widgets/dialogs/dlg_turn_order.cpp src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 1f780b795..128899a13 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -441,6 +442,37 @@ QList GameScene::collectActivePlayers(int &firstPlayerIndex) cons } } } + + // Lay out the table in seat order rather than id order. Players missing + // from the tracked order keep id order at the end. + QList seatOrder; + for (auto *pgItem : playerViews.values()) { + if (PlayerLogic *p = pgItem->getLogic()) { + seatOrder = p->getGame()->getGameState()->getSeatOrder(); + break; + } + } + if (!seatOrder.isEmpty()) { + std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) { + int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId()); + int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId()); + if (indexA == -1) { + indexA = seatOrder.size() + a->getPlayerInfo()->getId(); + } + if (indexB == -1) { + indexB = seatOrder.size() + b->getPlayerInfo()->getId(); + } + return indexA < indexB; + }); + // Recompute the local-player index after sorting. + firstPlayerIndex = 0; + for (int i = 0; i < activePlayers.size(); ++i) { + if (activePlayers[i]->getPlayerInfo()->getLocal()) { + firstPlayerIndex = i; + break; + } + } + } return activePlayers; } diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 046a1ae90..8b351e022 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -605,6 +605,20 @@ void MessageLogWidget::logReverseTurn(PlayerLogic *player, bool reversed) .arg(reversed ? tr("reversed") : tr("normal"))); } +void MessageLogWidget::logTurnOrderChanged(QStringList playerNames, bool randomized) +{ + QStringList sanitizedNames; + for (const QString &name : playerNames) { + sanitizedNames.append(sanitizeHtml(name)); + } + const QString order = sanitizedNames.join(" -> "); + if (randomized) { + appendHtmlServerMessage(tr("Randomized turn order: %1.").arg(order)); + } else { + appendHtmlServerMessage(tr("New turn order set: %1.").arg(order)); + } +} + void MessageLogWidget::logRollDie(PlayerLogic *player, int sides, const QList &rolls) { if (rolls.length() == 1) { diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 5642a76e9..5632f5af8 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -94,6 +94,7 @@ public slots: int amount, bool isLentToAnotherPlayer); void logReverseTurn(PlayerLogic *player, bool reversed); + void logTurnOrderChanged(QStringList playerNames, bool randomized); void logRollDie(PlayerLogic *player, int sides, const QList &rolls); void logSay(PlayerLogic *player, QString message); void logSetActivePhase(int phase); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 104b695e6..ada4237f7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -116,14 +116,17 @@ void DlgCreateGame::sharedCtor() shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); + shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start")); + createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); auto *gameSetupOptionsLayout = new QGridLayout; gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); - gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0); + gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); + gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0); + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); } else { createGameAsJudgeCheckBox->setChecked(false); createGameAsJudgeCheckBox->setHidden(true); @@ -184,6 +187,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameType createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator()); startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal()); shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad()); + shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers()); if (!rememberGameSettings->isChecked()) { actReset(); @@ -217,6 +221,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetEnabled(false); startingLifeTotalEdit->setEnabled(false); shareDecklistsOnLoadCheckBox->setEnabled(false); + shufflePlayersCheckBox->setEnabled(false); descriptionEdit->setText(QString::fromStdString(gameInfo.description())); maxPlayersEdit->setValue(gameInfo.max_players()); @@ -227,6 +232,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); + shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players()); QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { @@ -264,6 +270,7 @@ void DlgCreateGame::actReset() startingLifeTotalEdit->setValue(20); shareDecklistsOnLoadCheckBox->setChecked(false); + shufflePlayersCheckBox->setChecked(false); createGameAsJudgeCheckBox->setChecked(false); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -294,6 +301,7 @@ void DlgCreateGame::actOK() cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); cmd.set_starting_life_total(startingLifeTotalEdit->value()); cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); + cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked()); auto _gameTypes = QString(); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -318,6 +326,7 @@ void DlgCreateGame::actOK() SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); + SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked()); SettingsCache::instance().game().setGameTypes(_gameTypes); } PendingCommand *pend = room->prepareRoomCommand(cmd); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 71b09997c..58356c9b6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -51,6 +51,7 @@ private: QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *shufflePlayersCheckBox; QDialogButtonBox *buttonBox; QPushButton *clearButton; QCheckBox *rememberGameSettings; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp new file mode 100644 index 000000000..8814f89db --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp @@ -0,0 +1,136 @@ +#include "dlg_turn_order.h" + +#include +#include +#include +#include +#include +#include +#include + +DlgTurnOrder::DlgTurnOrder(const QStringList &_playerNames, QWidget *parent) + : QDialog(parent), randomizeRequested(false) +{ + listWidget = new QListWidget(this); + listWidget->addItems(_playerNames); + listWidget->setCurrentRow(0); + connect(listWidget, &QListWidget::currentRowChanged, this, &DlgTurnOrder::selectionChanged); + + moveUpButton = new QPushButton(this); + connect(moveUpButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveUp); + moveDownButton = new QPushButton(this); + connect(moveDownButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveDown); + randomizeButton = new QPushButton(this); + connect(randomizeButton, &QPushButton::clicked, this, &DlgTurnOrder::actRandomize); + + // Shown in place of the list while the server is asked to randomize: the + // order is chosen server side, so there is nothing to preview here. + hintLabel = new QLabel(this); + hintLabel->setWordWrap(true); + hintLabel->setAlignment(Qt::AlignCenter); + + auto *buttonColumn = new QVBoxLayout; + buttonColumn->addWidget(moveUpButton); + buttonColumn->addWidget(moveDownButton); + buttonColumn->addWidget(randomizeButton); + buttonColumn->addStretch(); + + auto *listLayout = new QHBoxLayout; + listLayout->addWidget(listWidget); + listLayout->addWidget(hintLabel, 1); + listLayout->addLayout(buttonColumn); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTurnOrder::actOk); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgTurnOrder::reject); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(listLayout); + mainLayout->addWidget(buttonBox); + setLayout(mainLayout); + + updateButtons(); + + updateMode(); + setMinimumWidth(300); +} + +void DlgTurnOrder::retranslateUi() +{ + setWindowTitle(tr("Set turn order")); + moveUpButton->setText(tr("Move up")); + moveDownButton->setText(tr("Move down")); + randomizeButton->setText(randomizeRequested ? tr("Set order manually") : tr("Randomize")); + hintLabel->setText(tr("The server will randomize the turn order.")); +} + +QStringList DlgTurnOrder::order() const +{ + QStringList playerNames; + for (int i = 0; i < listWidget->count(); ++i) { + playerNames.append(listWidget->item(i)->text()); + } + return playerNames; +} + +void DlgTurnOrder::moveSelected(int delta) +{ + const int row = listWidget->currentRow(); + const int newRow = row + delta; + if (row < 0 || newRow < 0 || newRow >= listWidget->count()) { + return; + } + + QListWidgetItem *item = listWidget->takeItem(row); + listWidget->insertItem(newRow, item); + listWidget->setCurrentRow(newRow); + updateButtons(); +} + +void DlgTurnOrder::updateMode() +{ + listWidget->setVisible(!randomizeRequested); + hintLabel->setVisible(randomizeRequested); + moveUpButton->setVisible(!randomizeRequested); + moveDownButton->setVisible(!randomizeRequested); + retranslateUi(); +} + +void DlgTurnOrder::updateButtons() +{ + const int row = listWidget->currentRow(); + const int count = listWidget->count(); + const bool manualOrder = !randomizeRequested; + moveUpButton->setEnabled(manualOrder && row > 0); + moveDownButton->setEnabled(manualOrder && row >= 0 && row < count - 1); +} + +void DlgTurnOrder::actMoveUp() +{ + moveSelected(-1); +} + +void DlgTurnOrder::actMoveDown() +{ + moveSelected(1); +} + +void DlgTurnOrder::actRandomize() +{ + // Randomizing is a request to the server, not a local shuffle: showing a + // preview would let the host re-open the dialog until the order looks + // favourable for them. + randomizeRequested = !randomizeRequested; + updateMode(); + updateButtons(); +} + +void DlgTurnOrder::actOk() +{ + accept(); +} + +void DlgTurnOrder::selectionChanged() +{ + updateButtons(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h new file mode 100644 index 000000000..44d8203b9 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h @@ -0,0 +1,51 @@ +/** + * @file dlg_turn_order.h + * @ingroup GameDialogs + */ +//! \todo Document this file. + +#ifndef DLG_TURN_ORDER_H +#define DLG_TURN_ORDER_H + +#include +#include + +class QLabel; +class QListWidget; +class QPushButton; + +class DlgTurnOrder : public QDialog +{ + Q_OBJECT +private: + QListWidget *listWidget; + QLabel *hintLabel; + QPushButton *moveUpButton; + QPushButton *moveDownButton; + QPushButton *randomizeButton; + bool randomizeRequested; + + void moveSelected(int delta); + void updateMode(); + void updateButtons(); + +private slots: + void actMoveUp(); + void actMoveDown(); + void actRandomize(); + void actOk(); + void selectionChanged(); + +public: + explicit DlgTurnOrder(const QStringList &_playerNames, QWidget *parent = nullptr); + + void retranslateUi(); + + [[nodiscard]] bool isRandomizeRequested() const + { + return randomizeRequested; + } + [[nodiscard]] QStringList order() const; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 315d51d9e..8fed95833 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -185,6 +185,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const if (gameentry.share_decklists_on_load()) { result.append(tr("open decklists")); } + if (gameentry.shuffle_players()) { + result.append(tr("random turn order")); + } return result.join(", "); } case Qt::DecorationRole: { diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index cbff850a4..34d6f8a51 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -20,6 +20,7 @@ #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_invite_to_game.h" +#include "../interface/widgets/dialogs/dlg_turn_order.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/utility/completer_utils.h" @@ -195,6 +196,7 @@ void TabGame::connectToGameState() { connect(game->getGameState(), &GameState::gameStarted, this, &TabGame::startGame); connect(game->getGameState(), &GameState::gameStopped, this, &TabGame::stopGame); + connect(game->getGameState(), &GameState::hostChanged, this, &TabGame::updateTurnOrderAction); connect(game->getGameState(), &GameState::activePhaseChanged, this, &TabGame::setActivePhase); connect(game->getGameState(), &GameState::activePlayerChanged, this, &TabGame::setActivePlayer); } @@ -263,6 +265,8 @@ void TabGame::connectMessageLogToGameEventHandler() connect(game->getGameEventHandler(), &GameEventHandler::logTurnReversed, messageLog, &MessageLogWidget::logReverseTurn); + connect(game->getGameEventHandler(), &GameEventHandler::logTurnOrderChanged, messageLog, + &MessageLogWidget::logTurnOrderChanged); connect(game->getGameEventHandler(), &GameEventHandler::logConcede, messageLog, &MessageLogWidget::logConcede); connect(game->getGameEventHandler(), &GameEventHandler::logUnconcede, messageLog, &MessageLogWidget::logUnconcede); @@ -373,6 +377,9 @@ void TabGame::retranslateUi() if (aReverseTurn) { aReverseTurn->setText(tr("Reverse turn order")); } + if (aTurnOrder) { + aTurnOrder->setText(tr("&Set turn order...")); + } if (aRemoveLocalArrows) { aRemoveLocalArrows->setText(tr("&Remove all local arrows")); } @@ -629,6 +636,73 @@ void TabGame::actConcede() } } +void TabGame::actTurnOrder() +{ + if (!canSetTurnOrder()) { + return; + } + + QStringList playerNames; + const QList seatOrder = game->getGameState()->getSeatOrder(); + const auto &players = game->getPlayerManager()->getPlayers(); + if (!seatOrder.isEmpty()) { + for (int playerId : seatOrder) { + if (PlayerLogic *player = players.value(playerId, nullptr)) { + playerNames.append(player->getPlayerInfo()->getName()); + } + } + // Include any players missing from the tracked order rather than dropping them. + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + const QString name = it.value()->getPlayerInfo()->getName(); + if (!playerNames.contains(name)) { + playerNames.append(name); + } + } + } else { + for (PlayerLogic *player : players) { + playerNames.append(player->getPlayerInfo()->getName()); + } + } + + DlgTurnOrder dlg(playerNames, this); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + game->getGameEventHandler()->handleSetPlayerOrder(dlg.order(), dlg.isRandomizeRequested()); +} + +/** + * Whether the local user may change the seating order: only while the game has + * not started, and only as the host or a moderator - the server allows both + * (Server_AbstractParticipant::cmdSetPlayerOrder). + */ +bool TabGame::canSetTurnOrder() const +{ + if (game->getGameMetaInfo()->started()) { + return false; + } + if (game->isHost()) { + return true; + } + const ServerInfo_User *userInfo = tabSupervisor->getUserInfo(); + return userInfo != nullptr && (userInfo->user_level() & ServerInfo_User::IsModerator); +} + +/** + * Shows the turn order action only when it can actually change anything: the + * player must be allowed to set the order and the game must not have started. + * Called whenever either condition may have changed (game start/stop, host + * change). + */ +void TabGame::updateTurnOrderAction() +{ + if (aTurnOrder == nullptr) { + return; + } + aTurnOrder->setVisible(canSetTurnOrder()); +} + /** * Confirms the leave game and sends the leave game command, if applicable. * @@ -840,6 +914,15 @@ void TabGame::processPlayerLeave(PlayerLogic *leavingPlayer) } } + // A shuffled local player has their seat changed, so the deck view container + // keyed by their previous id must be cleaned up alongside the leaving player. + int leavingPlayerId = leavingPlayer->getPlayerInfo()->getId(); + if (deckViewContainers.contains(leavingPlayerId)) { + TabbedDeckViewContainer *deckView = deckViewContainers.take(leavingPlayerId); + deckViewContainerLayout->removeWidget(deckView); + deckView->deleteLater(); + } + scene->removePlayer(leavingPlayer); } @@ -948,6 +1031,7 @@ void TabGame::startGame(bool _resuming) aConcede->setText(tr("&Concede")); aConcede->setEnabled(true); } + updateTurnOrderAction(); } void TabGame::stopGame() @@ -970,6 +1054,7 @@ void TabGame::stopGame() aConcede->setText(tr("&Concede")); aConcede->setEnabled(false); } + updateTurnOrderAction(); } void TabGame::closeGame() @@ -1094,6 +1179,9 @@ void TabGame::createMenuItems() connect(aNextTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleNextTurn); aReverseTurn = new QAction(this); connect(aReverseTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleReverseTurn); + aTurnOrder = new QAction(this); + connect(aTurnOrder, &QAction::triggered, this, &TabGame::actTurnOrder); + updateTurnOrderAction(); aRemoveLocalArrows = new QAction(this); connect(aRemoveLocalArrows, &QAction::triggered, this, &TabGame::actRemoveLocalArrows); aRotateViewCW = new QAction(this); @@ -1144,6 +1232,7 @@ void TabGame::createMenuItems() gameMenu->addAction(aNextTurn); aNextTurn->setObjectName("aNextTurn"); gameMenu->addAction(aReverseTurn); + gameMenu->addAction(aTurnOrder); gameMenu->addSeparator(); gameMenu->addAction(aRemoveLocalArrows); gameMenu->addAction(aRotateViewCW); @@ -1172,6 +1261,7 @@ void TabGame::createReplayMenuItems() aNextPhaseAction = nullptr; aNextTurn = nullptr; aReverseTurn = nullptr; + aTurnOrder = nullptr; aRemoveLocalArrows = nullptr; aRotateViewCW = nullptr; aRotateViewCCW = nullptr; @@ -1302,6 +1392,7 @@ void TabGame::createPlayAreaWidget(bool bReplay) scene = new GameScene(phasesToolbar, this); connect(game->getPlayerManager(), &PlayerManager::playerConceded, scene, &GameScene::rearrange); connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, scene, &GameScene::rearrange); + connect(game->getGameState(), &GameState::seatOrderChanged, scene, &GameScene::rearrange); connect(scene, &GameScene::arrowDeletionRequested, game->getGameEventHandler(), &GameEventHandler::handleArrowDeletion); connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 7bc52202f..412529dc2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -94,7 +94,8 @@ private: QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, - *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; + *aReverseTurn, *aTurnOrder, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, + *aResetReplayLayout; QAction *aFocusChat; QAction *aInviteToGame = nullptr; QList phaseActions; @@ -129,6 +130,7 @@ private: PlayerLogic *setActivePlayer(int id); void setActivePhase(int phase); + bool canSetTurnOrder() const; void createMenuItems(); void createReplayMenuItems(); void createViewMenuItems(); @@ -162,6 +164,8 @@ private slots: void actInviteToGame(); void actConcede(); void actCopyGameLink(); + void actTurnOrder(); + void updateTurnOrderAction(); void actRemoveLocalArrows(); void actRotateViewCW(); void actRotateViewCCW(); diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp index 856475dfa..7ef502400 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -189,6 +192,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont, case GameEvent::REVERSE_TURN: eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context); break; + case GameEvent::PLAYER_ORDER_CHANGED: + eventPlayerOrderChanged(event.GetExtension(Event_PlayerOrderChanged::ext), playerId, context); + break; default: { PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0); @@ -215,6 +221,16 @@ void GameEventHandler::handleReverseTurn() sendGameCommand(Command_ReverseTurn()); } +void GameEventHandler::handleSetPlayerOrder(const QList &playerNames, bool randomize) +{ + Command_SetPlayerOrder command; + command.set_randomize(randomize); + for (const QString &name : playerNames) { + command.add_player_names(name.toStdString()); + } + sendGameCommand(command); +} + void GameEventHandler::handleActiveLocalPlayerConceded() { sendGameCommand(Command_Concede()); @@ -377,6 +393,27 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event { const int playerListSize = event.player_list_size(); + if (event.has_local_player_id()) { + game->getPlayerManager()->setLocalPlayerId(event.local_player_id()); + } + + QSet authoritativePlayerIds; + for (int i = 0; i < playerListSize; ++i) { + const ServerInfo_PlayerProperties &prop = event.player_list(i).properties(); + if (!prop.spectator()) { + authoritativePlayerIds.insert(prop.player_id()); + } + } + + // Players whose seats were shuffled away from the previous game are no longer + // part of the authoritative list and must be removed. + for (int stalePlayerId : game->getPlayerManager()->getPlayers().keys()) { + if (!authoritativePlayerIds.contains(stalePlayerId)) { + game->getPlayerManager()->removePlayer(stalePlayerId); + emit playerLeft(stalePlayerId); + } + } + QVector>> opponentDecksToDisplay; for (int i = 0; i < playerListSize; ++i) { @@ -421,6 +458,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event emit remotePlayersDecksSelected(opponentDecksToDisplay); + // The server emits player_list in seat order, so record it to lay out the + // table by seating rather than by player id. + QList seatOrder; + for (int i = 0; i < playerListSize; ++i) { + const ServerInfo_PlayerProperties &prop = event.player_list(i).properties(); + if (!prop.spectator()) { + seatOrder.append(prop.player_id()); + } + } + if (!seatOrder.isEmpty()) { + game->getGameState()->setSeatOrder(seatOrder); + } + game->getGameState()->setGameTime(event.seconds_elapsed()); if (event.game_started() && !game->getGameMetaInfo()->started()) { @@ -625,6 +675,33 @@ void GameEventHandler::eventReverseTurn(const Event_ReverseTurn &event, emit logTurnReversed(player, event.reversed()); } +void GameEventHandler::eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, + int /*eventPlayerId*/, + const GameEventContext & /*context*/) +{ + QStringList playerNames; + for (const auto &name : event.player_names()) { + playerNames.append(QString::fromStdString(name)); + } + + // Track the seating by stable player id so the table layout and the turn + // order dialog follow the shuffled order instead of id order. + QList seatOrder; + const auto &players = game->getPlayerManager()->getPlayers(); + for (const QString &name : playerNames) { + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + if (it.value() && it.value()->getPlayerInfo()->getName() == name) { + seatOrder.append(it.key()); + break; + } + } + } + if (!seatOrder.isEmpty()) { + game->getGameState()->setSeatOrder(seatOrder); + } + emit logTurnOrderChanged(playerNames, event.randomized()); +} + void GameEventHandler::eventGameHostChanged(const Event_GameHostChanged & /*event*/, int eventPlayerId, const GameEventContext & /*context*/) diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h index f7b11780b..bfcf355b0 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -51,6 +51,7 @@ class Event_GameSay; class Event_Kicked; class Event_ReverseTurn; class Event_Ping; +class Event_PlayerOrderChanged; class ServerInfo_PlayerProperties; namespace google { @@ -113,6 +114,16 @@ public: /** @brief Request reversing the current turn order. */ void handleReverseTurn(); + /** + * @brief Request a new seating / turn order. + * + * Only the game host (or a moderator) may do this before the game starts. + * + * @param playerNames Ordered list of player names. Ignored when randomize is true. + * @param randomize Ask the server to shuffle the seats instead. + */ + void handleSetPlayerOrder(const QList &playerNames, bool randomize); + /** @brief Concede the game for the currently active local player. */ void handleActiveLocalPlayerConceded(); @@ -316,6 +327,10 @@ public: /** @brief Handle a turn reversal event. */ void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context); + /** @brief Handle a change of the seating / turn order. */ + void + eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, int eventPlayerId, const GameEventContext &context); + /** @brief Handle ping / latency updates. */ void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context); @@ -443,6 +458,7 @@ signals: void logLeave(PlayerLogic *player, QString reason); void logKicked(); void logTurnReversed(PlayerLogic *player, bool reversed); + void logTurnOrderChanged(QStringList playerNames, bool randomized); void logGameClosed(); void logActivePlayer(PlayerLogic *activePlayer); void logActivePhaseChanged(int activePhase); diff --git a/libcockatrice_game/libcockatrice/game/game_state.h b/libcockatrice_game/libcockatrice/game/game_state.h index 55784fb91..4d02019ce 100644 --- a/libcockatrice_game/libcockatrice/game/game_state.h +++ b/libcockatrice_game/libcockatrice/game/game_state.h @@ -33,7 +33,11 @@ public: void setHostId(int _hostId) { + if (hostId == _hostId) { + return; + } hostId = _hostId; + emit hostChanged(hostId); } QList getClients() const @@ -112,6 +116,19 @@ public: return hostId; } + void setSeatOrder(const QList &order) + { + if (seatOrder != order) { + seatOrder = order; + emit seatOrderChanged(seatOrder); + } + } + + QList getSeatOrder() const + { + return seatOrder; + } + int getSecondsElapsed() const { return secondsElapsed; @@ -123,6 +140,8 @@ signals: void gameStopped(); void activePhaseChanged(int activePhase); void activePlayerChanged(int playerId); + void hostChanged(int hostId); + void seatOrderChanged(const QList &seatOrder); public slots: void incrementGameTime(); @@ -142,6 +161,9 @@ private: int currentPhase; int activePlayer; bool gameClosed; + //! Player ids in seating (turn) order, first to play first. Empty until the + //! server sends an order, in which case players are laid out in id order. + QList seatOrder; }; #endif // COCKATRICE_GAME_STATE_H diff --git a/libcockatrice_game/libcockatrice/game/player/player_manager.h b/libcockatrice_game/libcockatrice/game/player/player_manager.h index c95bbbb96..950574112 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_manager.h +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.h @@ -57,6 +57,11 @@ public: return localPlayerId; } + void setLocalPlayerId(int _localPlayerId) + { + localPlayerId = _localPlayerId; + } + [[nodiscard]] const QMap &getPlayers() const { return players; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h index 2b1ddf8ec..00763d031 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h @@ -20,6 +20,7 @@ public: [[nodiscard]] virtual bool getCreateGameAsSpectator() const = 0; [[nodiscard]] virtual int getDefaultStartingLifeTotal() const = 0; [[nodiscard]] virtual bool getShareDecklistsOnLoad() const = 0; + [[nodiscard]] virtual bool getShufflePlayers() const = 0; [[nodiscard]] virtual bool getRememberGameSettings() const = 0; [[nodiscard]] virtual bool getLocalGameRememberSettings() const = 0; [[nodiscard]] virtual int getLocalGameMaxPlayers() const = 0; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h index baf946632..0661bd2aa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h @@ -21,6 +21,7 @@ struct GameConfig bool spectatorsSeeEverything = true; int startingLifeTotal = 20; bool shareDecklistsOnLoad = true; + bool shufflePlayers = false; }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index ff8ea14f3..dcd14d14c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -434,6 +435,33 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_ return Response::RespOk; } +Response::ResponseCode Server_AbstractParticipant::cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + if ((game->getHostId() != playerId) && !(userInfo->user_level() & ServerInfo_User::IsModerator)) { + return Response::RespFunctionNotAllowed; + } + + if (game->getGameStarted()) { + return Response::RespContextError; + } + + if (cmd.randomize()) { + game->shufflePlayerSeats(); + } else { + QList playerNames; + for (int i = 0; i < cmd.player_names_size(); ++i) { + playerNames.append(QString::fromStdString(cmd.player_names(i))); + } + game->reorderPlayerSeats(playerNames); + } + + // Clients learn the new seating from the Event_PlayerOrderChanged that + // reseatPlayers broadcasts; no full game state resend is needed for it. + return Response::RespOk; +} + Response::ResponseCode Server_AbstractParticipant::processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges) { @@ -543,6 +571,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::SET_PLAYMAT: return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges); break; + case GameCommand::SET_PLAYER_ORDER: + return cmdSetPlayerOrder(command.GetExtension(Command_SetPlayerOrder::ext), rc, ges); + break; default: return Response::RespInvalidCommand; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index b6d4a8025..316b9727c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -53,6 +53,7 @@ class Command_DeckSelect; class Command_SetSideboardLock; class Command_ChangeZoneProperties; class Command_SetPlaymat; +class Command_SetPlayerOrder; class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container { @@ -89,6 +90,10 @@ public: { return playerId; } + void setPlayerId(int _playerId) + { + playerId = _playerId; + } bool isSpectator() const { return roleInfo.isSpectator; @@ -173,6 +178,8 @@ public: virtual Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges); virtual Response::ResponseCode + cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges); Response::ResponseCode processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 49db999f6..e2724523e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -73,11 +73,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -91,8 +93,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room) spectatorsAllowed(config.spectatorsAllowed), spectatorsNeedPassword(config.spectatorsNeedPassword), spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything), startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad), - inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), - turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), + shufflePlayers(config.shufflePlayers), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), + firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), deckValidationStrategy(new Server_DefaultDeckValidationStrategy), lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy), gameMutex() @@ -292,6 +294,32 @@ int Server_Game::getSpectatorCount() const return result; } +QList Server_Game::getParticipantsInSeatOrder() const +{ + // Players in seat order first (fall back to id order for seats predating + // seatOrder), then spectators. Player ids are stable; only the ordering + // changes on shuffle/reorder. + QList ordered; + ordered.reserve(participants.size()); + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator() && !ordered.contains(participant)) { + ordered.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !ordered.contains(participant)) { + ordered.append(participant); + } + } + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + ordered.append(participant); + } + } + return ordered; +} + void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, Server_AbstractParticipant *recipient, bool omniscient, @@ -306,16 +334,24 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, event->set_game_started(false); } - for (Server_AbstractParticipant *participant : participants.values()) { + // Emit the player list in seat order so every client (including old ones + // that ignore Event_PlayerOrderChanged) sees the current seating. Player + // ids are stable; only this ordering changes on shuffle/reorder. + QList ordered = getParticipantsInSeatOrder(); + for (Server_AbstractParticipant *participant : ordered) { participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo); } + + if (recipient != nullptr) { + event->set_local_player_id(recipient->getPlayerId()); + } } void Server_Game::sendGameStateToPlayers() { // game state information for replay and omniscient spectators Event_GameStateChanged omniscientEvent; - createGameStateChangedEvent(&omniscientEvent, nullptr, true, false); + createGameStateChangedEvent(&omniscientEvent, nullptr, true, true); GameEventContainer *replayCont = prepareGameEvent(omniscientEvent, -1); replayCont->set_seconds_elapsed(secondsElapsed - startTimeOfThisGame); @@ -327,7 +363,7 @@ void Server_Game::sendGameStateToPlayers() // the data we used for the replay. All spectators are equal, so we don't need to make a createGameStateChangedEvent // call for each one. Event_GameStateChanged spectatorNormalEvent; - createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, false); + createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, true); // send game state info to clients according to their role in the game for (auto *participant : participants.values()) { @@ -340,7 +376,7 @@ void Server_Game::sendGameStateToPlayers() } } else { Event_GameStateChanged event; - createGameStateChangedEvent(&event, participant, participant->isJudge(), false); + createGameStateChangedEvent(&event, participant, participant->isJudge(), true); gec = prepareGameEvent(event, -1); } @@ -380,6 +416,11 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) return; } + if (shufflePlayers && players.size() > 1) { + shufflePlayerSeats(); + players = getPlayers(); + } + for (Server_AbstractPlayer *player : players.values()) { player->setupZones(); } @@ -433,6 +474,116 @@ void Server_Game::startGameIfReady(bool forceStartGame) emit sigStartGameIfReady(forceStartGame); } +void Server_Game::shufflePlayerSeats() +{ + QMutexLocker locker(&gameMutex); + + // Collect players in current seat order (fall back to id order for seats + // predating seatOrder). Spectators keep their seats. + QList players; + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator()) { + players.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !players.contains(participant)) { + players.append(participant); + } + } + QList spectators; + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + spectators.append(participant); + } + } + + // Fisher-Yates shuffle of the players. + QList shuffledPlayers; + while (!players.isEmpty()) { + const int index = rng->rand(0, players.size() - 1); + shuffledPlayers.append(players.takeAt(index)); + } + + reseatPlayers(shuffledPlayers + spectators, true); +} + +void Server_Game::reorderPlayerSeats(const QList &orderedNames) +{ + QMutexLocker locker(&gameMutex); + + // Collect players in current seat order (fall back to id order for seats + // predating seatOrder). Spectators keep their seats. + QList players; + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator()) { + players.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !players.contains(participant)) { + players.append(participant); + } + } + QList spectators; + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + spectators.append(participant); + } + } + + // Order the players according to the given names, appending any player that + // was not listed (should not happen) in their current seat order. + QList orderedPlayers; + QSet placed; + for (const QString &name : orderedNames) { + for (auto *participant : players) { + if (!placed.contains(participant) && (QString::fromStdString(participant->getUserInfo()->name()) == name)) { + orderedPlayers.append(participant); + placed.insert(participant); + break; + } + } + } + for (auto *participant : players) { + if (!placed.contains(participant)) { + orderedPlayers.append(participant); + } + } + + reseatPlayers(orderedPlayers + spectators, false); +} + +void Server_Game::reseatPlayers(const QList &orderedParticipants, bool randomized) +{ + QMutexLocker locker(&gameMutex); + + // Keep player ids stable and only reorder the seating/turn order. Changing + // ids would break old clients (stale localPlayerId from Event_GameJoined + // duplicates their own seat as an opponent), invalidate the home-server + // per-user game/seat mapping used for ISL command routing, and orphan the + // host id. All of those maps stay valid because ids never change here. + // The current host therefore keeps its id and needs no re-key tracking. + seatOrder.clear(); + for (auto *participant : orderedParticipants) { + if (!participant->isSpectator()) { + seatOrder.append(participant->getPlayerId()); + } + } + + // Broadcast the new seating order so that every client can display it. + Event_PlayerOrderChanged event; + for (auto *participant : orderedParticipants) { + if (!participant->isSpectator()) { + event.add_player_names(QString::fromStdString(participant->getUserInfo()->name()).toStdString()); + } + } + event.set_randomized(randomized); + sendGameEventContainer(prepareGameEvent(event, -1)); +} + void Server_Game::stopGameIfFinished() { QMutexLocker locker(&gameMutex); @@ -563,6 +714,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, allSpectatorsEver.insert(playerName); } else { allPlayersEver.insert(playerName); + seatOrder.append(newParticipant->getPlayerId()); // if the original creator of the game joins, give them host status back //! \todo Transferring host to spectators has side effects. @@ -592,6 +744,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve room->getServer()->removePersistentPlayer(QString::fromStdString(participant->getUserInfo()->name()), room->getId(), gameId, participant->getPlayerId()); participants.remove(participant->getPlayerId()); + seatOrder.removeAll(participant->getPlayerId()); bool spectator = participant->isSpectator(); GameEventStorage ges; @@ -611,10 +764,20 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve participant->prepareDestroy(); if (playerHost) { + // Pass host to the next player in seat order so the game always keeps + // a host while players remain. int newHostId = -1; - for (auto *otherPlayer : getPlayers().values()) { - newHostId = otherPlayer->getPlayerId(); - break; + for (int id : seatOrder) { + if (participants.contains(id)) { + newHostId = id; + break; + } + } + if (newHostId == -1) { + for (auto *otherPlayer : getPlayers().values()) { + newHostId = otherPlayer->getPlayerId(); + break; + } } if (newHostId != -1) { hostId = newHostId; @@ -771,7 +934,20 @@ void Server_Game::nextTurn() } auto players = getPlayers(); - const QList keys = players.keys(); + // Advance in seat order, not id order, so shuffles/reorders change the + // turn sequence while ids stay stable. + QList keys; + keys.reserve(players.size()); + for (int id : seatOrder) { + if (players.contains(id)) { + keys.append(id); + } + } + for (int id : players.keys()) { + if (!keys.contains(id)) { + keys.append(id); + } + } int listPos = -1; if (activePlayer != -1) { listPos = keys.indexOf(activePlayer); @@ -821,7 +997,7 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti event2.set_active_phase(activePhase); bool omniscient = (joiningParticipant->isSpectator() && spectatorsSeeEverything) || joiningParticipant->isJudge(); - for (auto *participant : participants.values()) { + for (auto *participant : getParticipantsInSeatOrder()) { participant->getInfo(event2.add_player_list(), joiningParticipant, omniscient, true); } @@ -950,6 +1126,7 @@ void Server_Game::getInfo(ServerInfo_Game &result) const result.set_spectators_can_chat(spectatorsCanTalk); result.set_spectators_omniscient(spectatorsSeeEverything); result.set_share_decklists_on_load(shareDecklistsOnLoad); + result.set_shuffle_players(shufflePlayers); result.set_spectators_count(getSpectatorCount()); result.set_start_time(startTime.toSecsSinceEpoch()); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index 363c0ca94..23ba96598 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -74,6 +74,11 @@ private: int hostId; ServerInfo_User *creatorInfo; QMap participants; + // Seating/turn order as an ordered list of player ids. Player ids themselves + // are stable for the lifetime of the participant; shuffling or reordering + // only permutes this list so old clients, ISL routes and persistent + // references (all keyed by player id) keep working. + QList seatOrder; QSet allPlayersEver, allSpectatorsEver; bool gameStarted; bool gameClosed; @@ -90,6 +95,7 @@ private: bool spectatorsSeeEverything; int startingLifeTotal; bool shareDecklistsOnLoad; + bool shufflePlayers; int inactivityCounter; int startTimeOfThisGame, secondsElapsed; bool firstGameStarted; @@ -109,6 +115,8 @@ private: Server_AbstractParticipant *recipient, bool omniscient, bool withUserInfo); + QList getParticipantsInSeatOrder() const; + void reseatPlayers(const QList &orderedParticipants, bool randomized); void storeGameInformation(); bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const; signals: @@ -149,6 +157,10 @@ public: { return participants; } + QList getSeatOrder() const + { + return seatOrder; + } int getGameId() const { return gameId; @@ -189,6 +201,10 @@ public: { return shareDecklistsOnLoad; } + bool getShufflePlayers() const + { + return shufflePlayers; + } Response::ResponseCode checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions); bool containsUser(const QString &userName) const; @@ -202,6 +218,8 @@ public: bool kickParticipant(int playerId); void startGameIfReady(bool forceStartGame); void stopGameIfFinished(); + void shufflePlayerSeats(); + void reorderPlayerSeats(const QList &orderedNames); int getActivePlayer() const { return activePlayer; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index f9cadc9aa..78b9d955f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1116,6 +1116,7 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room int startingLifeTotal = cmd.has_starting_life_total() ? cmd.starting_life_total() : 20; bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false; + bool shufflePlayers = cmd.has_shuffle_players() ? cmd.shuffle_players() : false; const int gameId = databaseInterface->getNextGameId(); if (gameId == -1) { @@ -1137,7 +1138,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room .spectatorsCanTalk = cmd.spectators_can_talk(), .spectatorsSeeEverything = cmd.spectators_see_everything(), .startingLifeTotal = startingLifeTotal, - .shareDecklistsOnLoad = shareDecklistsOnLoad}; + .shareDecklistsOnLoad = shareDecklistsOnLoad, + .shufflePlayers = shufflePlayers}; auto *game = new Server_Game(config, room); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 059e83096..5fba2631d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -66,6 +66,7 @@ set(PROTO_FILES command_set_card_attr.proto command_set_card_counter.proto command_set_counter.proto + command_set_player_order.proto command_set_playmat.proto command_set_sideboard_lock.proto command_set_sideboard_plan.proto @@ -110,6 +111,7 @@ set(PROTO_FILES event_list_rooms.proto event_move_card.proto event_notify_user.proto + event_player_order_changed.proto event_player_properties_changed.proto event_remove_from_list.proto event_remove_messages.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto new file mode 100644 index 000000000..97e27038f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; +import "game_commands.proto"; +message Command_SetPlayerOrder { + extend GameCommand { + optional Command_SetPlayerOrder ext = 1206; + } + + // The desired seating order, expressed as player names. Only used when + // randomize is false. + repeated string player_names = 1; + + // When set, the server shuffles the seating order instead of using + // player_names. + optional bool randomize = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto index 5c8aee3fe..bbf2d0f98 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto @@ -24,4 +24,9 @@ message Event_GameStateChanged { // the amount of seconds since the game started optional uint32 seconds_elapsed = 5; + + // the id of the player this event is addressed to. Used to re-identify the + // local player after seats have been shuffled. Only set for events sent to + // a specific participant, never for omniscient/replay events. + optional sint32 local_player_id = 6; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto new file mode 100644 index 000000000..d119398b7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "game_event.proto"; +message Event_PlayerOrderChanged { + extend GameEvent { + optional Event_PlayerOrderChanged ext = 2023; + } + + // The new seating order, expressed as player names. + repeated string player_names = 1; + + // Whether the order was obtained by randomizing rather than set explicitly. + optional bool randomized = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 2e5b88978..eb399ffb9 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -180,6 +180,11 @@ message GameCommand { /// Server: Server_Player::cmdSetPlaymat /// Client: reflected via player properties changed event SET_PLAYMAT = 1035; + + /// Set the seating (turn) order of the players. + /// Server: Server_AbstractParticipant::cmdSetPlayerOrder + /// Client: reflected via Event_PlayerOrderChanged + SET_PLAYER_ORDER = 1206; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto index 7d3147701..573d26453 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto @@ -34,6 +34,7 @@ message GameEvent { CHANGE_ZONE_PROPERTIES = 2020; REVERSE_TURN = 2021; GAME_LOG_NOTICE = 2022; + PLAYER_ORDER_CHANGED = 2023; } optional sint32 player_id = 1 [default = -1]; extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index 9d9c6d277..3be7e4e64 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -78,6 +78,9 @@ message Command_CreateGame { // share decklists with all players when selected optional bool share_decklists_on_load = 14; + + // shuffle the player order (seats) before each game starts + optional bool shuffle_players = 15; } message Command_JoinGame { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto index 9989ae18a..39ec79914 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto @@ -48,6 +48,9 @@ message ServerInfo_Game { // decklists are sent to all players when loaded optional bool share_decklists_on_load = 15; + // players are shuffled into new seats before each game starts + optional bool shuffle_players = 16; + // the current player count optional uint32 player_count = 30; diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp index c8c6463e3..89fe7b85e 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp @@ -71,6 +71,11 @@ bool GameSettings::getShareDecklistsOnLoad() const return getValue("shareDecklistsOnLoad", "game").toBool(); } +bool GameSettings::getShufflePlayers() const +{ + return getValue("shuffleplayers", "game").toBool(); +} + bool GameSettings::getRememberGameSettings() const { return getValue("rememberGameSettings", "game", QString(), true).toBool(); @@ -151,6 +156,11 @@ void GameSettings::setShareDecklistsOnLoad(bool _shareDecklistsOnLoad) setValue(_shareDecklistsOnLoad, "shareDecklistsOnLoad", "game"); } +void GameSettings::setShufflePlayers(bool _shufflePlayers) +{ + setValue(_shufflePlayers, "shuffleplayers", "game"); +} + void GameSettings::setRememberGameSettings(bool _rememberGameSettings) { setValue(_rememberGameSettings, "rememberGameSettings", "game"); diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.h b/libcockatrice_settings/libcockatrice/settings/game_settings.h index 3e64234fc..45ec8554b 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.h @@ -27,6 +27,7 @@ public: [[nodiscard]] bool getCreateGameAsSpectator() const override; [[nodiscard]] int getDefaultStartingLifeTotal() const override; [[nodiscard]] bool getShareDecklistsOnLoad() const override; + [[nodiscard]] bool getShufflePlayers() const override; [[nodiscard]] bool getRememberGameSettings() const override; [[nodiscard]] bool getLocalGameRememberSettings() const override; [[nodiscard]] int getLocalGameMaxPlayers() const override; @@ -44,6 +45,7 @@ public: void setCreateGameAsSpectator(bool _createGameAsSpectator); void setDefaultStartingLifeTotal(int _defaultStartingLifeTotal); void setShareDecklistsOnLoad(bool _shareDecklistsOnLoad); + void setShufflePlayers(bool _shufflePlayers); void setRememberGameSettings(bool _rememberGameSettings); void setLocalGameRememberSettings(bool value); void setLocalGameMaxPlayers(int value); diff --git a/tests/movecard_tests/CMakeLists.txt b/tests/movecard_tests/CMakeLists.txt index 68d8fc84c..35d7b44ed 100755 --- a/tests/movecard_tests/CMakeLists.txt +++ b/tests/movecard_tests/CMakeLists.txt @@ -9,3 +9,15 @@ cockatrice_add_test( LABELS server ) + +cockatrice_add_test( + NAME + player_order_test + SOURCES + player_order_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) diff --git a/tests/movecard_tests/player_order_test.cpp b/tests/movecard_tests/player_order_test.cpp new file mode 100644 index 000000000..6e398af24 --- /dev/null +++ b/tests/movecard_tests/player_order_test.cpp @@ -0,0 +1,268 @@ +#include "game/game_config.h" +#include "game/role_info.h" +#include "game/server_abstract_participant.h" +#include "game/server_game.h" +#include "server_abstractuserinterface.h" +#include "server_response_containers.h" +#include "server_room.h" +#include "server_test_helpers.h" + +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server + +class ScriptedRng : public RNG_Abstract +{ +public: + QList values; + + unsigned int rand(int min, int max) override + { + const unsigned int value = values.takeFirst(); + EXPECT_GE(value, static_cast(min)); + EXPECT_LE(value, static_cast(max)); + return value; + } +}; + +class FakeUserInterface : public Server_AbstractUserInterface +{ +public: + FakeUserInterface(Server *_server, const ServerInfo_User &_userInfo) : Server_AbstractUserInterface(_server) + { + setUserInfo(_userInfo); + } + + int getLastCommandTime() const override + { + return 0; + } + + bool addSaidMessageSize(int) override + { + return true; + } + + void sendProtocolItem(const Response &) override + { + } + + void sendProtocolItem(const SessionEvent &) override + { + } + + void sendProtocolItem(const GameEventContainer &) override + { + } + + void sendProtocolItem(const RoomEvent &) override + { + } +}; + +namespace +{ +QStringList participantNamesInOrder(const Server_Game &game) +{ + QStringList names; + for (int id : game.getSeatOrder()) { + const auto *participant = game.getParticipants().value(id, nullptr); + if (participant) { + names.append(QString::fromStdString(participant->getUserInfo()->name())); + } + } + return names; +} + +QList participantIdsInOrder(const Server_Game &game) +{ + return game.getSeatOrder(); +} +} // namespace + +class PlayerOrderTest : public ::testing::Test +{ +protected: + ServerInfo_User creator; + ServerInfo_User userB; + ServerInfo_User userC; + + PlayerOrderTest() + { + creator.set_name("A"); + creator.set_user_level(ServerInfo_User::IsRegistered); + userB.set_name("B"); + userB.set_user_level(ServerInfo_User::IsRegistered); + userC.set_name("C"); + userC.set_user_level(ServerInfo_User::IsRegistered); + } +}; + +TEST_F(PlayerOrderTest, JoinOrderIsSeatOrder) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"A", "B", "C"})); + EXPECT_EQ(participantIdsInOrder(game), QList({0, 1, 2})); + EXPECT_EQ(game.getHostId(), 0); + EXPECT_EQ(game.getPlayerCount(), 3); +} + +TEST_F(PlayerOrderTest, ReorderPlayerSeats) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + game.reorderPlayerSeats({"C", "A", "B"}); + + // player ids are stable; only the seat order changes + EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"})); + EXPECT_EQ(participantIdsInOrder(game), QList({2, 0, 1})); + EXPECT_EQ(game.getParticipants().size(), 3); + + // the host keeps its seat: creator A is still in the game, so host stays 0 + EXPECT_EQ(game.getHostId(), 0); + + // no re-keying means every mapping keyed by player id stays valid + const int gameId = game.getGameId(); + EXPECT_EQ(uiA.getGames().value(gameId).second, 0); + EXPECT_EQ(uiB.getGames().value(gameId).second, 1); + EXPECT_EQ(uiC.getGames().value(gameId).second, 2); +} + +TEST_F(PlayerOrderTest, ReorderPlayerSeatsKeepsPlayerCount) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + // listing only a subset appends the remaining players in their current order + game.reorderPlayerSeats({"C"}); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"})); + EXPECT_EQ(participantIdsInOrder(game), QList({2, 0, 1})); + EXPECT_EQ(game.getPlayerCount(), 3); + EXPECT_EQ(game.getHostId(), 0); +} + +TEST_F(PlayerOrderTest, ShufflePlayerSeatsIsAPermutation) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + // fisher-yates on [A, B, C]: rand(0, 2) -> 1 (take B), rand(0, 1) -> 0 (take A), rand(0, 0) -> 0 (take C) + auto *scriptedRng = new ScriptedRng; + scriptedRng->values = {1, 0, 0}; + rng = scriptedRng; + + game.shufflePlayerSeats(); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"B", "A", "C"})); + EXPECT_EQ(participantIdsInOrder(game), QList({1, 0, 2})); + QSet idSet(participantIdsInOrder(game).cbegin(), participantIdsInOrder(game).cend()); + EXPECT_EQ(idSet.size(), 3); // ids are unique + // ids are stable, so the host (creator A, id 0) is unchanged + EXPECT_EQ(game.getHostId(), 0); +} From d9442733350e7d4034d48e385256316b1f39e109 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 11:06:58 -0500 Subject: [PATCH 230/247] [Home] Keep actions bounded and reachable at every window height (#7747) --- Testing/deck_editor_open_test.xml | 2 +- Testing/deck_editor_search_test.xml | 2 +- Testing/integration_test.xml | 2 +- .../widgets/general/home_styled_button.cpp | 4 +- .../interface/widgets/general/home_widget.cpp | 48 ++++++++++--------- .../interface/widgets/general/home_widget.h | 2 - 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Testing/deck_editor_open_test.xml b/Testing/deck_editor_open_test.xml index 437c4bd23..ab74b1c63 100644 --- a/Testing/deck_editor_open_test.xml +++ b/Testing/deck_editor_open_test.xml @@ -1,4 +1,4 @@ - + diff --git a/Testing/deck_editor_search_test.xml b/Testing/deck_editor_search_test.xml index 0b9f88fa1..6641ac3b2 100644 --- a/Testing/deck_editor_search_test.xml +++ b/Testing/deck_editor_search_test.xml @@ -1,6 +1,6 @@ - + diff --git a/Testing/integration_test.xml b/Testing/integration_test.xml index 5fc7a971a..33191d1ac 100644 --- a/Testing/integration_test.xml +++ b/Testing/integration_test.xml @@ -36,7 +36,7 @@ - + diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.cpp b/cockatrice/src/interface/widgets/general/home_styled_button.cpp index 9c31d3815..a2ac7912d 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.cpp +++ b/cockatrice/src/interface/widgets/general/home_styled_button.cpp @@ -21,8 +21,8 @@ class QWidget; HomeStyledButton::HomeStyledButton(const QString &text, QPair _gradientColors, QWidget *parent) : QPushButton(text, parent), gradientColors(_gradientColors) { - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - setMinimumHeight(25); + // Keep the font/style-derived height even when the home page has ample space. + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); setStyleSheet(generateButtonStylesheet(gradientColors)); applyInterfaceDefaultFont(); connect(themeManager, &ThemeManager::fontsChanged, this, &HomeStyledButton::applyInterfaceDefaultFont); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index e6b5bf255..db4501efc 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -27,10 +27,11 @@ #include #include #include -#include +#include #include #include #include +#include #include #include #include @@ -44,9 +45,9 @@ #include #include #include +#include #include #include -#include #include #include #include @@ -60,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -69,20 +71,25 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { setObjectName("homeWidget"); - layout = new QGridLayout(this); backgroundSourceCard = new CardInfoPictureArtCropWidget(this); gradientColors = determineButtonColor(); - layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); + // The launcher keeps its natural size on a large display and stays centred in the + // viewport. On a small window it scrolls, instead of its combined minimum height + // becoming the minimum height of the entire application. + auto *scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); + scrollArea->setObjectName("homeActionsScrollArea"); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidgetResizable(false); + scrollArea->setAlignment(Qt::AlignCenter); + // The home background is painted by this widget, so let it show through the viewport. + scrollArea->viewport()->setAutoFillBackground(false); + scrollArea->setWidget(createButtons()); - layout->setRowStretch(0, 1); - layout->setRowStretch(2, 1); - layout->setColumnStretch(0, 1); - layout->setColumnStretch(2, 1); - - setLayout(layout); + auto *layout = new QVBoxLayout(this); + layout->addWidget(scrollArea); cardChangeTimer = new QTimer(this); connect(cardChangeTimer, &QTimer::timeout, this, &HomeWidget::updateRandomCard); @@ -289,18 +296,10 @@ QGroupBox *HomeWidget::createButtons() { QGroupBox *box = new QGroupBox(this); box->setObjectName("homeButtonsBox"); - box->setStyleSheet(R"( - QGroupBox { - color: white; /* Title text color */ - background: transparent; - } - - QGroupBox::title { - color: white; - subcontrol-origin: margin; - subcontrol-position: top center; /* or top left / right */ - } -)"); + // The box only stacks the logo and the actions: no frame, and no background of its + // own, so the home background art stays visible. It never shows a title. The rule is + // load-bearing: QScrollArea::setWidget() turns autoFillBackground on for its widget. + box->setStyleSheet("QGroupBox { background: transparent; border: none; }"); // Family comes from the InterfaceDefault theme role; the 20px size stays caller-side. auto applyBoxFont = [box]() { QFont boxFont = themeManager->appFont(AppFont::InterfaceDefault); @@ -309,9 +308,12 @@ QGroupBox *HomeWidget::createButtons() }; applyBoxFont(); connect(themeManager, &ThemeManager::fontsChanged, this, applyBoxFont); - box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); + // The scroll area is not resizable, so the box has to size itself: SetFixedSize keeps + // it at its content size and re-fixes it whenever the labels or fonts change, which is + // what makes the scroll area's scroll bars follow the content. + boxLayout->setSizeConstraint(QLayout::SetFixedSize); logoLabel = new QLabel; logoLabel->setAlignment(Qt::AlignCenter); diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index aa4745c82..fec1db950 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -16,7 +16,6 @@ #include #include -class QGridLayout; class QLabel; class CardInfoPictureArtCropWidget; class ExactCard; @@ -46,7 +45,6 @@ public slots: void updateConnectButton(const ClientStatus status); private: - QGridLayout *layout; QTimer *cardChangeTimer; TabSupervisor *tabSupervisor; QPixmap background; From e558c0dffad615f5c8763a37bc04beeabf5e6035 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 11:08:54 -0500 Subject: [PATCH 231/247] [Banner] Keep horizontal user banner crops inside the artwork (#7742) --- .../widgets/cards/card_art_utils.cpp | 23 ++++- .../interface/widgets/cards/card_art_utils.h | 9 +- .../server/user/user_card_art_provider.cpp | 28 +----- .../server/user/user_card_art_provider.h | 3 - .../server/user/user_card_settings_dialog.cpp | 4 +- tests/CMakeLists.txt | 12 +++ tests/card_art_crop_test.cpp | 92 +++++++++++++++++++ 7 files changed, 139 insertions(+), 32 deletions(-) create mode 100644 tests/card_art_crop_test.cpp diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp index ebf79282e..a7e633ad2 100644 --- a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp @@ -2,6 +2,8 @@ #include "libcockatrice/card/card_info.h" +#include +#include #include #include #include @@ -18,4 +20,23 @@ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card) transform.rotate(90); return art.transformed(transform, Qt::SmoothTransformation); } -} // namespace CardArtUtils \ No newline at end of file + +QPixmap cropCardArt(const QPixmap &art, const ExactCard &card) +{ + if (art.isNull()) { + return {}; + } + + const QPixmap source = rotateSidewaysLayoutArt(art, card); + const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; + const QSize size = source.size(); + const int marginX = size.width() * 0.07; + // Landscape name boxes extend below 5% of the upright image. Remove the + // whole box here, rather than relying on the banner's narrow viewport to + // hide it: the same saved offset is also used by the taller profile popup. + const int topMargin = size.height() * (landscape ? 0.15 : 0.11); + const int bottomMargin = size.height() * 0.45; + const QRect crop(marginX, topMargin, size.width() - 2 * marginX, size.height() - topMargin - bottomMargin); + return source.copy(crop.intersected(source.rect())); +} +} // namespace CardArtUtils diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h index 5c331a12c..ac3b82380 100644 --- a/cockatrice/src/interface/widgets/cards/card_art_utils.h +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h @@ -20,6 +20,13 @@ namespace CardArtUtils * @return @p art rotated 90° clockwise for sideways-layout cards, else @p art. */ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card); + +/** + * Return the upright artwork used by banner previews, user rows and profile + * popups. The crop excludes conventional name and rules boxes before callers apply + * their saved pan and zoom, so a taller popup cannot expose the card frame. + */ +QPixmap cropCardArt(const QPixmap &art, const ExactCard &card); } // namespace CardArtUtils -#endif // CARD_ART_UTILS_H \ No newline at end of file +#endif // CARD_ART_UTILS_H diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index d84bd5804..6e8fd771a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -66,27 +65,6 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString processQueue(); } -QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card) -{ - QPixmap source = fullRes; - - // Sideways-layout cards (plane, siege/battle, split) store their landscape - // artwork rotated 90° inside a portrait frame. Rotate it upright first so - // the crop below lands on the horizontal art, mirroring the way - // CardInfoPictureWidget displays these cards. - const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; - source = CardArtUtils::rotateSidewaysLayoutArt(source, card); - - const QSize sz = source.size(); - const int marginX = sz.width() * 0.07; - const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11; - const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45; - - const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); - - return source.copy(artRect.intersected(source.rect())); -} - void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) { if (pixmap.isNull()) { @@ -134,7 +112,7 @@ void UserCardArtProvider::processQueue() // Synchronous hit (already loaded/on disk) if (!fullRes.isNull()) { - insertIntoCache(key, cropCardArt(fullRes, card)); + insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card)); pending.remove(key); emit cardArtUpdated(userName); @@ -158,7 +136,7 @@ void UserCardArtProvider::processQueue() CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (!fullRes.isNull()) { - self->insertIntoCache(key, self->cropCardArt(fullRes, card)); + self->insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card)); } self->pending.remove(key); @@ -172,4 +150,4 @@ void UserCardArtProvider::processQueue() // Stop here. We'll continue when the async load finishes. return; } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h index ce9225b00..a7844ca88 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -10,8 +10,6 @@ #include #include -class ExactCard; - class UserCardArtProvider : public QObject { Q_OBJECT @@ -21,7 +19,6 @@ public: void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId); const QMap &cache() const; - static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card); signals: void cardArtUpdated(const QString &userName); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp index f8b57cfc2..67fb57e78 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -2,6 +2,7 @@ #include "../../../card_picture_loader/card_picture_loader.h" #include "../../cards/art_crop_attribution.h" +#include "../../cards/card_art_utils.h" #include "../../utility/completer_utils.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -10,7 +11,6 @@ #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" -#include "user_card_art_provider.h" #include "user_list_painter.h" #include @@ -580,7 +580,7 @@ void UserCardArtSettingsDialog::reloadPreview() return; } - currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card); + currentPixmap = CardArtUtils::cropCardArt(fullRes, card); preview->setPixmap(currentPixmap); preview->setParams(currentParams); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c87bec627..e25390ba0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -220,6 +220,18 @@ cockatrice_add_test( libcockatrice_deck_list libcockatrice_utility ) +cockatrice_add_test( + NAME + card_art_crop_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/cards/card_art_utils.cpp + ${VERSION_STRING_CPP} + card_art_crop_test.cpp + LIBS + libcockatrice_card + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME server_rate_limiter_test diff --git a/tests/card_art_crop_test.cpp b/tests/card_art_crop_test.cpp new file mode 100644 index 000000000..1c4a9ff8d --- /dev/null +++ b/tests/card_art_crop_test.cpp @@ -0,0 +1,92 @@ +#include "interface/widgets/cards/card_art_utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +ExactCard makeCard(bool landscape) +{ + CardInfo::UiAttributes attributes; + attributes.landscapeOrientation = landscape; + return ExactCard(CardInfo::newInstance("Artwork", "", false, {}, {}, {}, {}, attributes)); +} + +QPixmap landscapeCardWithFrame() +{ + // Normalized landscape frame: the title reaches 14% down the upright + // image, and the Battle type line starts at 56%. Artwork has two colors + // so the test also detects a wrong or missing rotation. + QImage upright(1400, 1000, QImage::Format_RGB32); + upright.fill(Qt::red); + QPainter painter(&upright); + painter.fillRect(QRect(70, 140, 1260, 420), Qt::green); + painter.fillRect(QRect(700, 140, 630, 420), Qt::blue); + painter.end(); + QTransform storedOrientation; + storedOrientation.rotate(-90); + return QPixmap::fromImage(upright.transformed(storedOrientation)); +} + +TEST(CardArtCrop, LandscapeCropExcludesNameAndRulesBoxesAndRotatesUpright) +{ + const QImage crop = CardArtUtils::cropCardArt(landscapeCardWithFrame(), makeCard(true)).toImage(); + ASSERT_GT(crop.width(), crop.height()); + ASSERT_FALSE(crop.isNull()); + for (int y = 0; y < crop.height(); ++y) { + for (int x = 0; x < crop.width(); ++x) { + EXPECT_EQ(x < crop.width() / 2 ? QColor(Qt::green) : QColor(Qt::blue), crop.pixelColor(x, y)); + } + } +} + +TEST(CardArtCrop, PortraitCropRetainsItsExistingArtworkBounds) +{ + QImage source(745, 1040, QImage::Format_RGB32); + for (int y = 0; y < source.height(); ++y) { + for (int x = 0; x < source.width(); ++x) { + source.setPixelColor(x, y, QColor(x % 256, y % 256, (x + y) % 256)); + } + } + const QPixmap input = QPixmap::fromImage(source); + const QImage crop = CardArtUtils::cropCardArt(input, makeCard(false)).toImage(); + EXPECT_EQ(source.copy(QRect(52, 114, 641, 458)), crop); +} + +TEST(CardArtCrop, DevicePixelRatioDoesNotChangeTheArtworkPixels) +{ + QPixmap source = landscapeCardWithFrame(); + const QPixmap normal = CardArtUtils::cropCardArt(source, makeCard(true)); + source.setDevicePixelRatio(2.0); + const QPixmap highDpi = CardArtUtils::cropCardArt(source, makeCard(true)); + EXPECT_EQ(normal.size(), highDpi.size()); + EXPECT_EQ(2.0, highDpi.devicePixelRatio()); + QImage normalPixels = normal.toImage(); + QImage highDpiPixels = highDpi.toImage(); + normalPixels.setDevicePixelRatio(1.0); + highDpiPixels.setDevicePixelRatio(1.0); + EXPECT_EQ(normalPixels, highDpiPixels); +} + +TEST(CardArtCrop, EmptyAndSmallImagesStayWithinTheirBounds) +{ + EXPECT_TRUE(CardArtUtils::cropCardArt({}, makeCard(true)).isNull()); + QPixmap small(1, 1); + small.fill(Qt::green); + EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(true)).toImage()); + EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(false)).toImage()); +} +} // namespace + +int main(int argc, char **argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3a656c9c4afaf6a231529ca902499ab7a5592d74 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 11:33:49 -0500 Subject: [PATCH 232/247] Preserve installed card databases when Oracle refresh fails (#7739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Preserve installed card databases when Oracle refresh fails * Fix the Oracle refresh verdict and cover the parser write path A background refresh that already installed cards.xml exited 1 when the token save afterwards failed, so the client kept its old in-memory database and reported the update as failed. Return the same verdict main() already computes: a reject after the database was written is a successful update. Cover the writer Oracle actually uses: add a short-write death test for CockatriceXml4Parser::saveToFile, which reverted to QFile would truncate an installed database without any test noticing. Route a failed background token download through reject() instead of a modal box on a hidden wizard, which left the client waiting with no timeout and blocked later updates. Test the first non-space byte in place rather than copying the payload, and report an import refusal with the importer's own message. --------- Co-authored-by: Lukas Brübach --- .../card/database/parser/cockatrice_xml_3.cpp | 5 +- .../card/database/parser/cockatrice_xml_4.cpp | 5 +- oracle/CMakeLists.txt | 1 + oracle/src/database_writer.cpp | 52 +++++ oracle/src/database_writer.h | 17 ++ oracle/src/oracleimporter.cpp | 69 ++++-- oracle/src/oracleimporter.h | 22 ++ oracle/src/oraclewizard.cpp | 21 +- oracle/src/pages.cpp | 76 +++++-- oracle/src/pages.h | 1 + oracle/src/pagetemplates.cpp | 40 ++-- tests/carddatabase/carddatabase_test.cpp | 64 ++++++ tests/oracle/CMakeLists.txt | 1 + tests/oracle/oracle_importer_test.cpp | 215 ++++++++++++++++++ 14 files changed, 517 insertions(+), 72 deletions(-) create mode 100644 oracle/src/database_writer.cpp create mode 100644 oracle/src/database_writer.h diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index d40c1c063..8d5735db7 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -499,7 +500,7 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats, { Q_UNUSED(_formats); - QFile file(fileName); + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { return false; } @@ -539,5 +540,5 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats, xml.writeEndElement(); // cockatrice_carddatabase xml.writeEndDocument(); - return true; + return !xml.hasError() && file.commit(); } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 3ee1e808e..3965b80f0 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -682,7 +683,7 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats, const QString &sourceUrl, const QString &sourceVersion) { - QFile file(fileName); + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { return false; } @@ -730,5 +731,5 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats, xml.writeEndElement(); // cockatrice_carddatabase xml.writeEndDocument(); - return true; + return !xml.hasError() && file.commit(); } diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 587463572..f68f51f23 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -17,6 +17,7 @@ set(ORACLE_WIN32_QM_INSTALL_DIR "translations") # Sources # ------------------------ set(oracle_SOURCES + src/database_writer.cpp src/main.cpp src/oraclewizard.cpp src/oracleimporter.cpp diff --git a/oracle/src/database_writer.cpp b/oracle/src/database_writer.cpp new file mode 100644 index 000000000..e80aa853a --- /dev/null +++ b/oracle/src/database_writer.cpp @@ -0,0 +1,52 @@ +#include "database_writer.h" + +#include +#include +#include +#include + +namespace OracleDatabase +{ +QString validateXml(const QByteArray &data) +{ + QXmlStreamReader xml(data); + if (!xml.readNextStartElement() || xml.name() != QLatin1String("cockatrice_carddatabase")) { + return QCoreApplication::translate("OracleDatabase", "The download is not a Cockatrice card database."); + } + const auto version = xml.attributes().value("version"); + if (version != QLatin1String("3") && version != QLatin1String("4")) { + return QCoreApplication::translate("OracleDatabase", "The downloaded card database version is not supported."); + } + while (!xml.atEnd()) { + xml.readNext(); + } + if (xml.hasError()) { + return QCoreApplication::translate("OracleDatabase", + "The downloaded card database is incomplete or invalid: %1") + .arg(xml.errorString()); + } + return {}; +} + +bool saveXml(const QString &fileName, const QByteArray &data, QString *error) +{ + const QString validationError = validateXml(data); + if (!validationError.isEmpty()) { + if (error) { + *error = validationError; + } + return false; + } + QSaveFile file(fileName); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) { + if (error) { + *error = file.errorString(); + } + return false; + } + if (error) { + error->clear(); + } + return true; +} +} // namespace OracleDatabase diff --git a/oracle/src/database_writer.h b/oracle/src/database_writer.h new file mode 100644 index 000000000..173fd23ab --- /dev/null +++ b/oracle/src/database_writer.h @@ -0,0 +1,17 @@ +#ifndef ORACLE_DATABASE_WRITER_H +#define ORACLE_DATABASE_WRITER_H + +#include +#include + +namespace OracleDatabase +{ +// Validate the complete downloaded document without allocating a card database. +// Empty string means valid; otherwise returns a diagnostic suitable for the wizard. +QString validateXml(const QByteArray &data); +// Never truncate the installed database: validate, write a sibling temporary file, +// check the complete write and atomically commit it. +bool saveXml(const QString &fileName, const QByteArray &data, QString *error = nullptr); +} // namespace OracleDatabase + +#endif diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 477c54d21..c83c962e3 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -100,6 +100,10 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s bool OracleImporter::readSetsFromByteArray(QByteArray data) { + importSuccessful = false; + importError.clear(); + allSets.clear(); + rawSetsData.clear(); const RawJson::ScanProgressCallback progress = progressReporting ? [this]( @@ -764,7 +768,15 @@ int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); - importCancelled.storeRelease(0); + importSuccessful = false; + importError.clear(); + sets.clear(); + cards.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); + if (allSets.isEmpty()) { + return failImport(tr("No set has been selected for import.")); + } // Pre-allocate the cards hash to avoid rehashing during import. Keys are // distinct card names while raw ranges only count printings (AllPrintings @@ -786,9 +798,7 @@ int OracleImporter::startImport() for (const SetToDownload &curSetToParse : allSets) { if (importCancelled.loadAcquire()) { - // The wizard was closed mid-import: stop at the next set boundary so - // the caller can wait for this future without processing every set. - break; + return failImport(tr("The card database import was cancelled.")); } CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), @@ -798,30 +808,20 @@ int OracleImporter::startImport() // parse only this set's slice of the raw document so the whole JSON tree is // never kept in memory at once const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); - const qsizetype rangeEnd = rawRange.start + rawRange.length; - if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { - // rawSetsData is cleared by releaseSetData() while SetToDownload copies - // taken from getSets() keep their ranges, and nothing else enforces the - // pairing — so never index past the buffer on stale/mismatched ranges. - qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; - ++setIndex; - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; + if (rawRange.start < 0 || rawRange.length <= 0 || rawRange.start > rawSetsData.size() || + rawRange.length > rawSetsData.size() - rawRange.start) { + return failImport(tr("The source data for set %1 is unavailable. Load the sets file again.") + .arg(curSetToParse.getShortName())); } - // sliced() shares the buffer instead of deep-copying the slice; the largest - // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); QJsonParseError parseError; const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); if (parseError.error != QJsonParseError::NoError) { - qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" - << parseError.errorString(); - ++setIndex; - // Keep the progress accounting honest: a set that failed to parse - // still advanced the index, so report it (with zero imported cards) - // rather than letting SaveSetsPage's bar stall per failed set. - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; + return failImport( + tr("Could not import set %1: %2").arg(curSetToParse.getShortName(), parseError.errorString())); + } + if (!setDoc.isObject() || !setDoc.object().value("cards").isArray()) { + return failImport(tr("Set %1 does not contain a card list.").arg(curSetToParse.getShortName())); } // Only add the set to the database once its slice parsed cleanly; @@ -838,7 +838,14 @@ int OracleImporter::startImport() emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + if (importCancelled.loadAcquire()) { + return failImport(tr("The card database import was cancelled.")); + } + if (cards.isEmpty()) { + return failImport(tr("No cards were imported. The existing card database has been kept.")); + } applyLocalizedData(); + importSuccessful = true; emit setIndexChanged(0, setIndex, QString()); @@ -846,8 +853,23 @@ int OracleImporter::startImport() return setIndex; } +int OracleImporter::failImport(const QString &message) +{ + importSuccessful = false; + importError = message; + sets.clear(); + cards.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); + qWarning() << message; + return 0; +} + bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion) { + if (!importSuccessful) { + return false; + } CockatriceXml4Parser parser(new NoopCardSetPriorityController()); return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); @@ -861,6 +883,7 @@ void OracleImporter::releaseSetData() void OracleImporter::clear() { + prepareImport(); sets.clear(); cards.clear(); allSets.clear(); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index d872e184e..5ebe9cf36 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -198,6 +198,11 @@ private: * the whole import (or racing it). */ QAtomicInt importCancelled; + bool importSuccessful = false; + QString importError; + + // Discard the incomplete in-memory database; the installed file is untouched. + int failImport(const QString &message); /** * The ISO-639 language code whose foreignData is imported; "en" by default. @@ -278,7 +283,24 @@ public: * the wizard can hand over its decompressed buffer without copying it. */ bool readSetsFromByteArray(QByteArray data); + // Returns the complete selected-set count on success, zero on failure or cancellation. int startImport(); + bool hasSuccessfulImport() const + { + return importSuccessful; + } + const QString &getImportError() const + { + return importError; + } + // Call on the owning thread before scheduling a worker, so a cancellation + // arriving before that worker starts is not lost. + void prepareImport() + { + importCancelled.storeRelease(0); + importSuccessful = false; + importError.clear(); + } /** * @brief Requests an in-flight startImport() to stop at the next set boundary. * diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index c33d821a5..dc8c7ecb3 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -1,6 +1,7 @@ #include "oraclewizard.h" #include "client/settings/cache_settings.h" +#include "database_writer.h" #include "main.h" #include "oracleimporter.h" #include "pages.h" @@ -141,6 +142,13 @@ void OracleWizard::reject() } QWizard::reject(); + // A hidden wizard does not close the application's last visible window. + // Stop background refreshes explicitly so the hosting client receives a verdict, + // using the same rule as main(): a reject after the database was written is still + // a successful update, only a reject that wrote nothing counts as a failure. + if (backgroundMode) { + QCoreApplication::exit(hasSavedDatabase() ? 0 : 1); + } } void OracleWizard::runInBackground() @@ -212,17 +220,10 @@ void OracleWizard::disableButtons() bool OracleWizard::saveTokensToFile(const QString &fileName) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File open (w) failed for" << fileName; + QString error; + if (!OracleDatabase::saveXml(fileName, tokensData, &error)) { + qWarning() << error; return false; } - - if (file.write(tokensData) == -1) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - - file.close(); return true; } diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 4c664a38c..f5c38be1c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -1,6 +1,7 @@ #include "pages.h" #include "client/settings/cache_settings.h" +#include "database_writer.h" #include "main.h" #include "oracleimporter.h" #include "oraclewizard.h" @@ -43,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -169,7 +171,16 @@ LoadSetsResult loadSetsData(const QPointer &importer, QByteArray break; } - if (data.startsWith("<")) { + // Test the first non-space byte in place: trimmed() would duplicate the whole + // payload, which on the plain-XML path is the largest download we handle. + const auto firstNonSpace = std::find_if_not(data.cbegin(), data.cend(), + [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }); + const bool looksLikeXml = firstNonSpace != data.cend() && *firstNonSpace == '<'; + if (looksLikeXml) { + result.errorMessage = OracleDatabase::validateXml(data); + if (!result.errorMessage.isEmpty()) { + return result; + } result.ok = true; result.plainXml = true; result.xmlData = std::move(data); @@ -651,6 +662,10 @@ void LoadSetsPage::importFinished() } else if (!result.ok && !result.plainXml) { qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data."); } + if (!result.ok) { + wizard()->reject(); + return; + } emit readyToContinue(); return; } @@ -717,7 +732,7 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) bool SaveSetsPage::isComplete() const { - return !importActive; + return !importActive && importReady; } void SaveSetsPage::cleanupPage() @@ -730,8 +745,11 @@ void SaveSetsPage::cleanupPage() void SaveSetsPage::initializePage() { + importReady = false; retranslateUi(); if (wizard()->downloadedPlainXml) { + importReady = true; + emit completeChanged(); messageLog->hide(); progressBar->hide(); if (wizard()->backgroundMode) { @@ -757,6 +775,9 @@ void SaveSetsPage::initializePage() emit completeChanged(); const QPointer importer = wizard()->importer; + if (auto *activeImporter = importer.data()) { + activeImporter->prepareImport(); + } importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; }); importWatcher.setFuture(importFuture); } @@ -788,22 +809,28 @@ void SaveSetsPage::importFinished() const int setsImported = importWatcher.result(); const QPointer importer = wizard()->importer; + importReady = importer && importer->hasSuccessfulImport() && setsImported == totalSets; + const QString error = importer ? importer->getImportError() : tr("The card database importer is unavailable."); if (importer) { importer->releaseSetData(); } + wizard()->button(QWizard::NextButton)->setEnabled(importReady); + emit completeChanged(); - if (wizard()->backgroundMode) { - if (setsImported == 0) { - qWarning() << tr("No set has been imported."); + if (!importReady) { + const QString message = error.isEmpty() ? tr("No set has been imported.") : error; + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); } - emit readyToContinue(); return; } progressBar->setValue(progressBar->maximum()); - - if (setsImported == 0) { - QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); + if (wizard()->backgroundMode) { + emit readyToContinue(); } } @@ -854,6 +881,18 @@ void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QS bool SaveSetsPage::validatePage() { + if (!isComplete()) { + return false; + } + const auto failSave = [this](const QString &message) { + qWarning() << message; + if (wizard()->backgroundMode) { + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; QString defaultPath = SettingsCache::instance().getCardDatabasePath(); QString windowName = tr("Save card database"); QString fileType = tr("XML; card database (*.xml)"); @@ -872,24 +911,21 @@ bool SaveSetsPage::validatePage() QFileInfo fi(fileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; + return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath())); } if (wizard()->downloadedPlainXml) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - if (file.write(wizard()->xmlData) < 1) { - qDebug() << "File write (w) failed for" << fileName; - return false; + QString error; + if (!OracleDatabase::saveXml(fileName, wizard()->xmlData, &error)) { + return failSave(tr("The file could not be saved to %1: %2").arg(fileName, error)); } wizard()->xmlData.clear(); } else if (!wizard()->importer->saveToFile(fileName, wizard()->getCardSourceUrl(), wizard()->getCardSourceVersion())) { - QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName)); - return false; + // The importer refuses to write a database whose import did not finish, and + // reports the reason there; do not dress an import failure up as a disk failure. + const QString importError = wizard()->importer->getImportError(); + return failSave(importError.isEmpty() ? tr("The file could not be saved to %1").arg(fileName) : importError); } wizard()->markDatabaseSaved(); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 0530010ba..e29a8e84a 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -128,6 +128,7 @@ private: QFuture importFuture; int totalSets = 0; bool importActive = false; + bool importReady = false; protected: void initializePage() override; diff --git a/oracle/src/pagetemplates.cpp b/oracle/src/pagetemplates.cpp index dcc8e070c..7e5f30bb7 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -1,5 +1,6 @@ #include "pagetemplates.h" +#include "database_writer.h" #include "oraclewizard.h" #include @@ -184,9 +185,17 @@ void SimpleDownloadFilePage::actDownloadFinished() } QNetworkReply::NetworkError errorCode = reply->error(); if (errorCode != QNetworkReply::NoError) { - QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString())); - wizard()->enableButtons(); + const QString message = tr("Network error: %1.").arg(reply->errorString()); reply->deleteLater(); + // A hidden wizard cannot show a modal box: without an explicit stop it would sit + // here forever and the hosting client would wait with no timeout. + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->reject(); + return; + } + QMessageBox::critical(this, tr("Error"), message); + wizard()->enableButtons(); return; } @@ -218,6 +227,15 @@ void SimpleDownloadFilePage::actDownloadFinished() bool SimpleDownloadFilePage::saveToFile() { + const auto failSave = [this](const QString &message) { + qWarning() << message; + if (wizard()->backgroundMode) { + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; QString defaultPath = getDefaultSavePath(); QString windowName = getWindowTitle(); QString fileType = getFileType(); @@ -236,12 +254,11 @@ bool SimpleDownloadFilePage::saveToFile() QFileInfo fi(fileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; + return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath())); } if (!internalSaveToFile(fileName)) { - QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName)); - return false; + return failSave(tr("The file could not be saved to %1").arg(fileName)); } // clean saved downloadData @@ -252,17 +269,10 @@ bool SimpleDownloadFilePage::saveToFile() bool SimpleDownloadFilePage::internalSaveToFile(const QString &fileName) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File open (w) failed for" << fileName; + QString error; + if (!OracleDatabase::saveXml(fileName, downloadData, &error)) { + qWarning() << error; return false; } - - if (file.write(downloadData) == -1) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - - file.close(); return true; } diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 22cbe9074..b54205f30 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -36,6 +36,12 @@ #include #include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + namespace { @@ -204,6 +210,64 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) ASSERT_EQ(printings.first().getProperty("manacost"), "1R"); } +#ifdef Q_OS_UNIX +/** + * @brief A failed write keeps the previously installed card database intact. + * + * This is the writer Oracle uses to install a refreshed database, so the truncation + * bug has to be covered here and not only through OracleDatabase::saveXml. + */ +TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", QString(1024, 'x'), false, {}, {}, {}, setsInfo, attributes); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + const QString fileName = tempDir.filePath("cards.xml"); + + QFile installed(fileName); + ASSERT_TRUE(installed.open(QIODevice::WriteOnly)); + ASSERT_EQ(installed.write("installed database"), 18); + installed.close(); + + // A real filesystem write failure, isolated in the death-test child so the + // process-wide file limit never affects other cases or the parent runner. + ASSERT_EXIT( + { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limit; + limit.rlim_cur = 128; + limit.rlim_max = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + CockatriceXml4Parser writer(&controller); + _exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0); + }, + ::testing::ExitedWithCode(0), ""); + + ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); + EXPECT_EQ(installed.readAll(), "installed database"); +} +#endif + /** * @brief Path provider serving a sandboxed copy of the test card database. */ diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 6958673f7..1cc1ad952 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -9,6 +9,7 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/database_writer.cpp oracle_importer_test.cpp LIBS libcockatrice_card diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 83d77d465..259bae087 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,3 +1,4 @@ +#include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" #include "libcockatrice/card/card_info.h" @@ -6,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +33,12 @@ #include #include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + class OracleImporterTest : public ::testing::Test { protected: @@ -1140,6 +1149,212 @@ TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); } +namespace +{ +QByteArray refreshSource(const QString &name = "New Card") +{ + QJsonObject card{{"name", name}, {"text", "Rules text."}, {"layout", "normal"}, + {"type", "Artifact"}, {"number", "1"}, {"rarity", "common"}}; + QJsonObject set{{"code", "TST"}, {"name", "Test Set"}, {"type", "expansion"}, {"cards", QJsonArray{card}}}; + return QJsonDocument(QJsonObject{{"data", QJsonObject{{"TST", set}}}}).toJson(QJsonDocument::Compact); +} +} // namespace + +TEST_F(OracleImporterTest, FailedLazyParseKeepsInstalledDatabase) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("existing database"), 17); + original.close(); + + // The metadata scanner skips numeric values without converting them. Qt's + // per-set parser rejects an overflowing number after selection succeeds. + auto badSet = QJsonDocument::fromJson(refreshSource()).object().value("data").toObject().value("TST").toObject(); + auto goodSet = badSet; + goodSet["code"] = "AAA"; + goodSet["name"] = "A successful first set"; + auto badCard = badSet.value("cards").toArray().first().toObject(); + badCard["number"] = "overflow"; + badSet["cards"] = QJsonArray{badCard}; + QByteArray data = QJsonDocument(QJsonObject{{"data", QJsonObject{{"AAA", goodSet}, {"TST", badSet}}}}) + .toJson(QJsonDocument::Compact); + data.replace("\"number\":\"overflow\"", "\"number\":1e9999"); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getImportError().contains("TST")); + EXPECT_TRUE(importer->getCardList().isEmpty()); + EXPECT_FALSE(importer->saveToFile(path, {}, {})); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "existing database"); +} + +TEST_F(OracleImporterTest, EmptyImportCannotReplaceInstalledCards) +{ + const QByteArray source = R"({"data":{"TST":{"code":"TST","name":"Test","type":"expansion","cards":[]}}})"; + ASSERT_TRUE(importer->readSetsFromByteArray(source)); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_FALSE(importer->getImportError().isEmpty()); +} + +TEST_F(OracleImporterTest, MissingSourceBytesCannotProduceSuccessfulImport) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + const auto selected = importer->getSets(); + importer->releaseSetData(); + importer->getSets() = selected; + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getImportError().contains("TST")); +} + +TEST_F(OracleImporterTest, CancellationDuringFinalSetCannotSavePartialDatabase) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + QObject::connect( + importer, &OracleImporter::setIndexChanged, importer, + [this](int, int, const QString &name) { + if (!name.isEmpty()) { + importer->cancelImport(); + } + }, + Qt::DirectConnection); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getCardList().isEmpty()); +} + +TEST_F(OracleImporterTest, CancellationBeforeWorkerStartsIsNotLost) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + importer->prepareImport(); + importer->cancelImport(); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + importer->prepareImport(); + EXPECT_EQ(importer->startImport(), 1); + EXPECT_TRUE(importer->hasSuccessfulImport()); +} + +TEST_F(OracleImporterTest, NewImportReplacesPreviousInMemoryCards) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("Old Card"))); + ASSERT_EQ(importer->startImport(), 1); + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("New Card"))); + EXPECT_FALSE(importer->hasSuccessfulImport()); + ASSERT_EQ(importer->startImport(), 1); + EXPECT_EQ(importer->getCardList().size(), 1); + EXPECT_FALSE(importer->getCardList().contains("Old Card")); + EXPECT_TRUE(importer->getCardList().contains("New Card")); +} + +TEST_F(OracleImporterTest, FailedSourceLoadCannotReusePreviousSuccessfulImport) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + ASSERT_EQ(importer->startImport(), 1); + ASSERT_FALSE(importer->readSetsFromByteArray("invalid JSON")); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_EQ(importer->startImport(), 0); +} + +TEST_F(OracleImporterTest, SuccessfulRefreshCommitsCompleteXml) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + ASSERT_EQ(importer->startImport(), 1); + importer->releaseSetData(); + ASSERT_TRUE(importer->saveToFile(path, "test-source", "v1")); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + const QByteArray xml = file.readAll(); + EXPECT_TRUE(xml.contains("New Card")); + EXPECT_TRUE(xml.contains("test-source")); + EXPECT_TRUE(xml.contains("")); +} + +TEST(OracleDatabaseWriter, AcceptsCompleteSupportedVersions) +{ + EXPECT_TRUE(OracleDatabase::validateXml( + "") + .isEmpty()); + EXPECT_TRUE(OracleDatabase::validateXml("") + .isEmpty()); + EXPECT_FALSE( + OracleDatabase::validateXml("") + .isEmpty()); +} + +TEST(OracleDatabaseWriter, InvalidDownloadPreservesInstalledBytes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("tokens.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("installed tokens"), 16); + original.close(); + for (const QByteArray &payload : {QByteArray("server error"), + QByteArray(""), QByteArray()}) { + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, payload, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "installed tokens"); + original.close(); + } +} + +TEST(OracleDatabaseWriter, CompleteDownloadReplacesInstalledBytes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + const QByteArray data(""); + QString error = "previous failure"; + ASSERT_TRUE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_TRUE(error.isEmpty()); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + EXPECT_EQ(file.readAll(), data); +} + +#ifdef Q_OS_UNIX +TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("installed database"), 18); + original.close(); + const QByteArray data = ""; + // A real filesystem write failure, isolated in the death-test child so the + // process-wide file limit never affects other cases or the parent runner. + ASSERT_EXIT( + { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limit; + limit.rlim_cur = 128; + limit.rlim_max = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + _exit(OracleDatabase::saveXml(path, data) ? 1 : 0); + }, + ::testing::ExitedWithCode(0), ""); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "installed database"); +} +#endif + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); From 0de92c06f870d7e7175967db616657399c69bf49 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 12:24:19 -0500 Subject: [PATCH 233/247] [Spoilers] Preserve spoiler files through update and reload failures (#7707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Preserve spoiler files and serialize update reloads * Surface spoiler removal reload failures and allow disabled-state retry * Address review feedback on spoiler update lifecycle Only remove the spoiler file when the user unchecks the setting; opening Settings no longer queues an unrequested reload for a leftover file. Remove the spoilersUpdatedSuccessfully signal, which had no receiver. * Fix spoiler settings state after reopen and while disabled Reopen Settings while an application-owned update is running: the new page now observes that updater, so the button reads Updating... and re-enables on completion instead of offering a second update that cancels the first. Observation does not take ownership: only updaters the page creates are retired with deleteLater, because the launch check is stack-allocated. When spoilers are off, the hint now describes what Reload Card Database actually does, stays readable, and wraps instead of clipping. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../spoiler_background_updater.cpp | 448 ++++++++++-------- .../card_spoiler/spoiler_background_updater.h | 46 +- .../update/card_spoiler/spoiler_file.cpp | 109 +++++ .../update/card_spoiler/spoiler_file.h | 24 + .../deck_editor_settings_page.cpp | 86 +++- .../settings_page/deck_editor_settings_page.h | 5 + tests/CMakeLists.txt | 9 + tests/spoiler_file_test.cpp | 149 ++++++ 9 files changed, 648 insertions(+), 229 deletions(-) create mode 100644 cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp create mode 100644 cockatrice/src/client/network/update/card_spoiler/spoiler_file.h create mode 100644 tests/spoiler_file_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b18846079..b76783167 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -15,6 +15,7 @@ set(cockatrice_SOURCES src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/release_channel.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp + src/client/network/update/card_spoiler/spoiler_file.cpp src/client/crash/crash_reporter.cpp src/client/latency_graph_widget.cpp src/client/latency_status_widget.cpp diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 62668c4d9..d74414d4a 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -2,253 +2,305 @@ #include "../../../../main.h" #include "../../../settings/cache_settings.h" +#include "spoiler_file.h" -#include -#include -#include +#include #include -#include #include #include #include -#include -#include -#include -#include #include #include #include -#include #include -#include +#include #include #include -#include -#include +#include #include #include #include #include #include -#include -#include #include -#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled" -#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml" - -SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr) +namespace { - isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus(); - if (isSpoilerDownloadEnabled) { - // Start the process of checking if we're in spoiler season - // File exists means we're in spoiler season - startSpoilerDownloadProcess(SPOILERS_STATUS_URL, false); +const QUrl SPOILERS_STATUS_URL( + QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled")); +const QUrl SPOILERS_URL(QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml")); +// Request coordination stays on the GUI thread; reload workers never access it. +QPointer currentUpdater; +QPointer> activeReload; +quint64 newestOperation = 0; + +bool removeSpoilerFile(const QString &path) +{ + QFile file(path); + if (!file.exists()) { + return false; + } + if (!file.remove()) { + qCWarning(SpoilerBackgroundUpdaterLog) << "Could not remove spoiler database" << path << file.errorString(); + return false; + } + return true; +} +} // namespace + +SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *parent, bool forceReload) + : QObject(parent), enabledAtStart(SettingsCache::instance().downloads().getDownloadSpoilersStatus()), + forceReload(forceReload), targetPath(SettingsCache::instance().paths().getSpoilerCardDatabasePath()), + operationId(++newestOperation), manager(NetworkManagerFactory::create(this)), notificationIcon(trayIcon) +{ + connect(&SettingsCache::instance().downloads(), &DownloadSettings::downloadSpoilerStatusChanged, this, + &SpoilerBackgroundUpdater::cancelIfSettingsChanged); + connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this, + &SpoilerBackgroundUpdater::cancelIfSettingsChanged); + // Even a disabled/no-op operation completes after its caller can connect signals. + QTimer::singleShot(0, this, &SpoilerBackgroundUpdater::begin); +} + +void SpoilerBackgroundUpdater::begin() +{ + if (completed) { + return; + } + if (cancelled || operationId != newestOperation || + (reloadOnly ? SettingsCache::instance().downloads().getDownloadSpoilersStatus() + : (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) || + targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { + finish(); + return; + } + const auto previous = currentUpdater; + if (activeReload) { + // The existing database API stores its final load status after its parser + // mutex is released. Do not overlap updater-owned calls to that API. + connect(activeReload, &QFutureWatcher::finished, this, &SpoilerBackgroundUpdater::begin, + static_cast(Qt::QueuedConnection | Qt::SingleShotConnection)); + if (previous && previous != this) { + previous->cancel(); + } + return; + } + currentUpdater = this; + QPointer guard(this); + if (previous && previous != this) { + previous->cancel(); + } + if (!guard) { + return; + } + if (!isCurrent()) { + finish(); + } else if (reloadOnly) { + if (QFileInfo::exists(targetPath) && !removeSpoilerFile(targetPath)) { + finish(tr("The spoiler database could not be removed. Check its file permissions and use Reload Card " + "Database to retry.")); + return; + } + reloadDatabase(false); } else { - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Disabled"; + downloadFromURL(SPOILERS_STATUS_URL, false); } } -void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool saveResults) +bool SpoilerBackgroundUpdater::isCurrent() const { - auto spoilerURL = QUrl(url); - downloadFromURL(spoilerURL, saveResults); + return !completed && !cancelled && currentUpdater == this && operationId == newestOperation && + (reloadOnly != SettingsCache::instance().downloads().getDownloadSpoilersStatus()) && + targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath(); } -void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults) +void SpoilerBackgroundUpdater::cancelIfSettingsChanged() { - auto *nam = NetworkManagerFactory::create(this); - auto request = QNetworkRequest(url); - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - QNetworkReply *reply = nam->get(request); + if (reloadOnly == SettingsCache::instance().downloads().getDownloadSpoilersStatus() || + targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { + cancel(); + } +} +void SpoilerBackgroundUpdater::cancel() +{ + if (completed) { + return; + } + cancelled = true; + const auto reply = activeReply; + activeReply.clear(); + QPointer guard(this); + if (reply) { + reply->abort(); + if (reply) { + reply->deleteLater(); + } + } + // A running reload owns no updater state. Allow its completion to retire its + // watcher before a heap-owned updater is released by the caller. + if (guard && !reloading) { + finish(); + } +} + +void SpoilerBackgroundUpdater::downloadFromURL(const QUrl &url, bool saveResults) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + request.setTransferTimeout(60000); + activeReply = manager->get(request); if (saveResults) { - // This will write out to the file (used for spoiler.xml) - connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile); + connect(activeReply, &QNetworkReply::finished, this, + &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile); } else { - // This will check the status (used to see if we're in spoiler season or not) - connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled); + connect(activeReply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled); + } +} + +void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() +{ + auto *reply = qobject_cast(sender()); + if (!reply || reply != activeReply) { + return; + } + activeReply.clear(); + reply->deleteLater(); + if (!isCurrent()) { + finish(); + return; + } + if (reply->error() == QNetworkReply::NoError) { + downloadFromURL(SPOILERS_URL, true); + } else if (reply->error() == QNetworkReply::ContentNotFoundError) { + const bool existed = QFileInfo::exists(targetPath); + seasonEnded = existed; + if (existed && !removeSpoilerFile(targetPath)) { + finish(tr("The spoiler season has ended, but the old spoiler database could not be removed.")); + return; + } + if (existed || forceReload) { + reloadDatabase(false); + } else { + finish(); + } + } else { + finish(tr("Could not check spoiler availability: %1").arg(reply->errorString())); } } void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile() { - // Check for server reply - auto *reply = dynamic_cast(sender()); - QNetworkReply::NetworkError errorCode = reply->error(); - - if (errorCode == QNetworkReply::NoError) { - spoilerData = reply->readAll(); - - // Save the spoiler.xml file to the disk - saveDownloadedFile(spoilerData); - - reply->deleteLater(); - emit spoilerCheckerDone(); + auto *reply = qobject_cast(sender()); + if (!reply || reply != activeReply) { + return; + } + activeReply.clear(); + reply->deleteLater(); + if (!isCurrent()) { + finish(); + return; + } + if (reply->error() != QNetworkReply::NoError) { + finish(tr("Could not download spoilers: %1").arg(reply->errorString())); + return; + } + spoilerData = reply->readAll(); + QString error; + const auto result = SpoilerFile::save(targetPath, spoilerData, [this] { return isCurrent(); }, error); + if (result == SpoilerFile::SaveResult::Failed) { + finish(isCurrent() ? error : QString()); + } else if (result == SpoilerFile::SaveResult::Written || forceReload) { + reloadDatabase(result == SpoilerFile::SaveResult::Written); } else { - qCWarning(SpoilerBackgroundUpdaterLog) << "Error downloading spoilers file" << errorCode; - emit spoilerCheckerDone(); + finish(); } } -bool SpoilerBackgroundUpdater::deleteSpoilerFile() +void SpoilerBackgroundUpdater::reloadDatabase(bool updated) { - QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); - QFileInfo fi(fileName); - QDir fileDir(fi.path()); - QFile file(fileName); - - // Delete the spoiler.xml file - if (file.exists() && file.remove()) { - qCInfo(SpoilerBackgroundUpdaterLog) << "Deleting spoiler.xml"; - return true; - } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Error: Spoiler.xml not found or not deleted"; - return false; -} - -void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() -{ - auto *response = dynamic_cast(sender()); - QNetworkReply::NetworkError errorCode = response->error(); - - if (errorCode == QNetworkReply::ContentNotFoundError) { - // Spoiler season is offline at this point, so the spoiler.xml file can be safely deleted - // The user should run Oracle to get the latest card information - if (deleteSpoilerFile() && trayIcon) { - trayIcon->showMessage(tr("Spoilers season has ended"), tr("Deleting spoiler.xml. Please run Oracle")); + reloading = true; + auto *database = CardDatabaseManager::getInstance(); + // The job outlives an individual request, so destroying that request cannot + // let a newer updater overlap the worker that is still completing. + auto *watcher = new QFutureWatcher(QCoreApplication::instance()); + activeReload = watcher; + connect(watcher, &QFutureWatcher::finished, watcher, [watcher] { + if (activeReload == watcher) { + activeReload.clear(); } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Season Offline"; - emit spoilerCheckerDone(); - } else if (errorCode == QNetworkReply::NoError) { - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Online"; - startSpoilerDownloadProcess(SPOILERS_URL, true); - } else if (errorCode == QNetworkReply::HostNotFoundError) { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers download failed"), tr("No internet connection")); - } - - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed due to no internet connection"; - emit spoilerCheckerDone(); - } else { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers download failed"), tr("Error") + " " + (short)errorCode); - } - - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed with reason" << errorCode; - emit spoilerCheckerDone(); - } -} - -bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data) -{ - QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); - QFileInfo fi(fileName); - QDir fileDir(fi.path()); - - if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; - } - - // Check if the data matches. If it does, then spoilers are up to date. - if (getHash(fileName) == getHash(data)) { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers already up to date"), tr("No new spoilers added")); - } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Up to Date"; - return false; - } - - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File open (w) failed for" << fileName; - file.close(); - return false; - } - - if (file.write(data) == -1) { - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File write (w) failed for" << fileName; - file.close(); - return false; - } - - file.close(); - - // Data written, so reload the card database - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written"; - // Spoilers can introduce sets the config has never seen; the loader never - // runs checkUnknownSets() itself, so run it once the finished snapshot has - // been swapped in. - auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [watcher] { watcher->deleteLater(); - CardDatabaseManager::getInstance()->checkUnknownSets(); }); - watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); })); - - // If the user has notifications enabled, let them know - // when the database was last updated - if (trayIcon) { - QList lines = data.split('\n'); - - for (const QByteArray &line : lines) { - if (line.contains("Created At:")) { - QString timeStamp = QString(line).replace("Created At:", "").trimmed(); - timeStamp.chop(6); // Remove " (UTC)" - - auto utcTime = QLocale().toDateTime(timeStamp, "ddd, MMM dd yyyy, hh:mm:ss"); -#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) - utcTime.setTimeZone(QTimeZone::UTC); -#else - utcTime.setTimeSpec(Qt::UTC); -#endif - - QString localTime = utcTime.toLocalTime().toString("MMM d, hh:mm"); - - trayIcon->showMessage(tr("Spoilers have been updated!"), tr("Last change:") + " " + localTime); - emit spoilersUpdatedSuccessfully(); - return true; - } + connect(watcher, &QFutureWatcher::finished, this, [this, watcher, database, updated] { + bool loaded = false; + try { + loaded = watcher->result() && database->getLoadStatus() == Ok; + } catch (const std::exception &error) { + qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler database reload failed:" << error.what(); } - } - - return true; + reloading = false; + if (!isCurrent()) { + finish(); + } else if (!loaded) { + finish(reloadOnly + ? tr("Could not reload card data after disabling spoilers. Use Reload Card Database to retry.") + : tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the " + "reload.")); + } else { + finish({}, updated); + } + }); + watcher->setFuture(QtConcurrent::run([database] { + database->reloadCardDatabasesAndNotify(); + return true; + })); } -QByteArray SpoilerBackgroundUpdater::getHash(const QString fileName) +void SpoilerBackgroundUpdater::finish(const QString &error, bool updated) { - QFile file(fileName); - - if (file.open(QFile::ReadOnly)) { - // Only read the first 512 bytes (enough to get the "created" tag) - const QByteArray bytes = file.read(512); - - QCryptographicHash hash(QCryptographicHash::Algorithm::Md5); - hash.addData(bytes); - - qCInfo(SpoilerBackgroundUpdaterLog) << "File Hash =" << hash.result(); - - file.close(); - return hash.result(); - } else { - qCWarning(SpoilerBackgroundUpdaterLog) << "getHash ReadOnly failed!"; - file.close(); - return QByteArray(); + if (completed) { + return; + } + completed = true; + spoilerData = QByteArray(); + if (currentUpdater == this) { + currentUpdater.clear(); + } + QPointer guard(this); + if (!error.isEmpty() && !cancelled) { + qCWarning(SpoilerBackgroundUpdaterLog) << error; + if (notificationIcon) { + notificationIcon->showMessage(tr("Spoiler update failed"), error); + } + if (!guard) { + return; + } + emit spoilerUpdateFailed(error); + } else if (updated && !cancelled) { + if (notificationIcon) { + notificationIcon->showMessage(tr("Spoilers have been updated!"), + tr("The card database now includes the downloaded spoilers.")); + } + } else if (seasonEnded && !cancelled && notificationIcon) { + notificationIcon->showMessage(tr("Spoiler season has ended"), + tr("The spoiler database was removed. Please run Oracle to get released cards.")); + } + if (guard) { + emit spoilerCheckerDone(); } } -QByteArray SpoilerBackgroundUpdater::getHash(QByteArray data) +SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::current() { - // Only read the first 512 bytes (enough to get the "created" tag) - const QByteArray bytes = data.left(512); - - QCryptographicHash hash(QCryptographicHash::Algorithm::Md5); - hash.addData(bytes); - - qCInfo(SpoilerBackgroundUpdaterLog) << "Data Hash =" << hash.result(); - - return hash.result(); + return currentUpdater; +} + +SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::deleteSpoilerFile() +{ + // Deletion and the following reload share the operation lifetime. Callers can + // observe either failure before the queued operation starts, and an explicit + // retry still reloads when the file has already been removed. + auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true); + updater->reloadOnly = true; + connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater); + return updater; } diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h index a6c4ed00f..fd90eb688 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h @@ -2,7 +2,6 @@ * @file spoiler_background_updater.h * @ingroup Client */ -//! \todo Document this file. #ifndef COCKATRICE_SPOILER_DOWNLOADER_H #define COCKATRICE_SPOILER_DOWNLOADER_H @@ -10,10 +9,13 @@ #include #include #include +#include #include #include -class QProcess; +class QNetworkAccessManager; +class QNetworkReply; +class QSystemTrayIcon; class QUrl; inline Q_LOGGING_CATEGORY(SpoilerBackgroundUpdaterLog, "spoiler_background_updater"); @@ -22,30 +24,48 @@ class SpoilerBackgroundUpdater : public QObject { Q_OBJECT public: - explicit SpoilerBackgroundUpdater(QObject *apParent = nullptr); + explicit SpoilerBackgroundUpdater(QObject *parent = nullptr, bool forceReload = false); inline QString getCardUpdaterBinaryName() { return "oracle"; } - QByteArray getHash(const QString fileName); - QByteArray getHash(QByteArray data); - static bool deleteSpoilerFile(); + // Queues removal and reload; the application owns the returned updater and + // deletes it on completion. Callers can connect its failure/completion signals. + static SpoilerBackgroundUpdater *deleteSpoilerFile(); + // The operation currently running, if any. The updater outlives the page that + // started it, so a reopened page uses this to observe the in-flight update. + static SpoilerBackgroundUpdater *current(); private slots: + void begin(); void actDownloadFinishedSpoilersFile(); void actCheckIfSpoilerSeasonEnabled(); + void cancelIfSettingsChanged(); private: - bool isSpoilerDownloadEnabled; - QProcess *cardUpdateProcess; + const bool enabledAtStart; + const bool forceReload; + const QString targetPath; + const quint64 operationId; + QNetworkAccessManager *manager; + QPointer activeReply; + QPointer notificationIcon; QByteArray spoilerData; - void startSpoilerDownloadProcess(QString url, bool saveResults); - void downloadFromURL(QUrl url, bool saveResults); - bool saveDownloadedFile(QByteArray data); + bool completed = false; + bool cancelled = false; + bool reloading = false; + bool seasonEnded = false; + bool reloadOnly = false; + + bool isCurrent() const; + void cancel(); + void finish(const QString &error = {}, bool updated = false); + void downloadFromURL(const QUrl &url, bool saveResults); + void reloadDatabase(bool updated); signals: - void spoilersUpdatedSuccessfully(); void spoilerCheckerDone(); + void spoilerUpdateFailed(const QString &message); }; -#endif // COCKATRICE_SPOILER_DOWNLOADER_H \ No newline at end of file +#endif // COCKATRICE_SPOILER_DOWNLOADER_H diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp new file mode 100644 index 000000000..554ea1108 --- /dev/null +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp @@ -0,0 +1,109 @@ +#include "spoiler_file.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QByteArray hashFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + QCryptographicHash hash(QCryptographicHash::Sha256); + return hash.addData(&file) ? hash.result() : QByteArray(); +} +} // namespace + +namespace SpoilerFile +{ +QString validate(const QByteArray &data) +{ + QXmlStreamReader xml(data); + if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The download is not a Cockatrice card database. The previous spoiler file was preserved."); + } + bool versionOk = false; + const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk); + if (!versionOk || (version != 3 && version != 4)) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The spoiler database uses an unsupported format. The previous spoiler file was preserved."); + } + bool hasCards = false; + int depth = 1; + while (!xml.atEnd()) { + const auto token = xml.readNext(); + if (token == QXmlStreamReader::StartElement) { + ++depth; + if (depth == 2 && xml.name() == QStringLiteral("cards")) { + hasCards = true; + } + } else if (token == QXmlStreamReader::EndElement) { + --depth; + } + } + if (xml.hasError() || !hasCards) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The spoiler database is incomplete or invalid. The previous spoiler file was preserved."); + } + return {}; +} + +SaveResult +save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error) +{ + error = validate(data); + if (!error.isEmpty()) { + return SaveResult::Failed; + } + if (!isCurrent()) { + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "The spoiler update was cancelled because its settings changed."); + return SaveResult::Failed; + } + if (hashFile(targetPath) == QCryptographicHash::hash(data, QCryptographicHash::Sha256)) { + return SaveResult::Unchanged; + } + const QDir directory = QFileInfo(targetPath).dir(); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) { + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "Could not create the spoiler database directory: %1") + .arg(directory.absolutePath()); + return SaveResult::Failed; + } + QSaveFile file(targetPath); + file.setDirectWriteFallback(false); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) { + error = QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "Could not save the spoiler database to %1: %2. The previous file was preserved.") + .arg(targetPath, file.errorString()); + return SaveResult::Failed; + } + if (!isCurrent()) { + file.cancelWriting(); + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "The spoiler update was cancelled because its settings changed."); + return SaveResult::Failed; + } + if (!file.commit()) { + error = QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "Could not replace the spoiler database at %1: %2. The previous file was preserved.") + .arg(targetPath, file.errorString()); + return SaveResult::Failed; + } + return SaveResult::Written; +} + +} // namespace SpoilerFile diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h new file mode 100644 index 000000000..3a138cdea --- /dev/null +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h @@ -0,0 +1,24 @@ +#ifndef COCKATRICE_SPOILER_FILE_H +#define COCKATRICE_SPOILER_FILE_H + +#include +#include +#include + +namespace SpoilerFile +{ +enum class SaveResult +{ + Failed, + Unchanged, + Written +}; + +QString validate(const QByteArray &data); +// Validate before opening the destination, compare complete files, and replace +// atomically. Check operation ownership both before writing and before committing. +SaveResult +save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error); +} // namespace SpoilerFile + +#endif // COCKATRICE_SPOILER_FILE_H diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 31178ba4c..79370f512 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -61,7 +62,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() connect(mpSpoilerPathButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::spoilerPathButtonClicked); updateNowButton = new QPushButton; - updateNowButton->setFixedWidth(150); + updateNowButton->setMinimumWidth(150); connect(updateNowButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::updateSpoilers); // Update the GUI depending on if the box is ticked or not @@ -120,12 +121,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() lpSpoilerGrid->addWidget(&lastUpdatedLabel, 2, 0); lpSpoilerGrid->addWidget(updateNowButton, 2, 1); lpSpoilerGrid->addWidget(&infoOnSpoilersLabel, 3, 0, 1, 3, Qt::AlignTop); + infoOnSpoilersLabel.setWordWrap(true); // On a change to the checkbox, hide/un-hide the other fields connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance().downloads(), &DownloadSettings::setDownloadSpoilerStatus); connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, this, &DeckEditorSettingsPage::setSpoilersEnabled); + // The updater is application-owned and outlives this page, so a Settings page + // reopened mid-update must observe the operation that is still running. + if (auto *runningUpdater = SpoilerBackgroundUpdater::current()) { + observeSpoilerUpdater(runningUpdater); + } + mpGeneralGroupBox = new QGroupBox; mpGeneralGroupBox->setLayout(lpGeneralGrid); @@ -328,20 +336,60 @@ void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const void DeckEditorSettingsPage::updateSpoilers() { - // Disable the button so the user can only press it once at a time - updateNowButton->setDisabled(true); - updateNowButton->setText(tr("Updating...")); + if (spoilerUpdater) { + return; + } + // Application ownership lets either operation finish after settings closes. + // When disabled, the same button retries file removal and the local reload. + SpoilerBackgroundUpdater *sbu; + if (SettingsCache::instance().downloads().getDownloadSpoilersStatus()) { + sbu = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true); + // Only an updater this page creates is retired by deleteLater: observation + // may attach to the stack-allocated launch updater, which must not be deleted. + connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, sbu, &QObject::deleteLater); + } else { + // deleteSpoilerFile() wires its own deleteLater. + sbu = SpoilerBackgroundUpdater::deleteSpoilerFile(); + } + observeSpoilerUpdater(sbu); +} - // Create a new SBU that will act as if the client was just reloaded - auto *sbu = new SpoilerBackgroundUpdater(); - connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, &DeckEditorSettingsPage::unlockSettings); - connect(sbu, &SpoilerBackgroundUpdater::spoilersUpdatedSuccessfully, this, &DeckEditorSettingsPage::unlockSettings); +void DeckEditorSettingsPage::observeSpoilerUpdater(SpoilerBackgroundUpdater *sbu) +{ + spoilerUpdater = sbu; + unlockSettings(); + connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, [this, sbu] { + if (spoilerUpdater == sbu) { + spoilerUpdater.clear(); + unlockSettings(); + } + }); + connect(sbu, &SpoilerBackgroundUpdater::spoilerUpdateFailed, this, [this, sbu](const QString &message) { + if (spoilerUpdater != sbu) { + return; + } + auto *notice = new QMessageBox(QMessageBox::Warning, tr("Spoiler update"), message, QMessageBox::Ok, this); + notice->setTextFormat(Qt::PlainText); + notice->setAttribute(Qt::WA_DeleteOnClose); + notice->open(); + }); } void DeckEditorSettingsPage::unlockSettings() { - updateNowButton->setDisabled(false); - updateNowButton->setText(tr("Update Spoilers")); + updateNowButton->setEnabled(!spoilerUpdater); + updateNowButton->setText(spoilerUpdater ? tr("Updating...") + : SettingsCache::instance().downloads().getDownloadSpoilersStatus() + ? tr("Update Spoilers") + : tr("Reload Card Database")); + lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); + infoOnSpoilersLabel.setText( + SettingsCache::instance().downloads().getDownloadSpoilersStatus() + ? tr("Spoilers download automatically on launch") + "\n" + + tr("Press the button to manually update without relaunching") + "\n\n" + + tr("Updates continue if you close settings") + : tr("Spoilers are off. Reload Card Database removes any downloaded spoiler file and reloads your " + "card data.")); } QString DeckEditorSettingsPage::getLastUpdateTime() @@ -379,12 +427,17 @@ void DeckEditorSettingsPage::setSpoilersEnabled(bool anInput) mpSpoilerSavePathLineEdit->setEnabled(anInput); mpSpoilerPathButton->setEnabled(anInput); lastUpdatedLabel.setEnabled(anInput); - updateNowButton->setEnabled(anInput); - infoOnSpoilersLabel.setEnabled(anInput); + // The hint explains the button in both states, especially the disabled-state + // Reload Card Database action, so keep it readable while spoilers are off. + infoOnSpoilersLabel.setEnabled(true); - if (!anInput) { - SpoilerBackgroundUpdater::deleteSpoilerFile(); + // Only react to the user disabling the setting. The constructor calls this to + // initialize the controls, and a leftover spoiler file is recovered through the + // Reload Card Database button rather than an unrequested reload on page open. + if (!anInput && sender() == &mcDownloadSpoilersCheckBox) { + observeSpoilerUpdater(SpoilerBackgroundUpdater::deleteSpoilerFile()); } + unlockSettings(); } void DeckEditorSettingsPage::retranslateUi() @@ -394,13 +447,10 @@ void DeckEditorSettingsPage::retranslateUi() mcDownloadSpoilersCheckBox.setText(tr("Download Spoilers Automatically")); mcSpoilerSaveLabel.setText(tr("Spoiler Location:")); lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); - infoOnSpoilersLabel.setText(tr("Spoilers download automatically on launch") + "\n" + - tr("Press the button to manually update without relaunching") + "\n\n" + - tr("Do not close settings until manual update is complete")); picDownloadCheckBox.setText(tr("Download card pictures on the fly")); urlLinkLabel.setText(QString("%2").arg(WIKI_CUSTOM_PIC_URL).arg(tr("How to add a custom URL"))); resetDownloadURLs.setText(tr("Reset Download URLs")); - updateNowButton->setText(tr("Update Spoilers")); + unlockSettings(); aAdd->setText(tr("Add New URL")); aEdit->setText(tr("Edit URL")); aRemove->setText(tr("Remove URL")); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h index 47f840191..8e33e697d 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ class QLineEdit; class QListWidget; class QListWidgetItem; class QModelIndex; +class SpoilerBackgroundUpdater; class DeckEditorSettingsPage : public AbstractSettingsPage { @@ -54,6 +56,7 @@ private: QLabel infoOnSpoilersLabel; QPushButton *mpSpoilerPathButton; QPushButton *updateNowButton; + QPointer spoilerUpdater; /** @brief Adds a list item for the given URL, storing the raw URL alongside its displayed label. */ QListWidgetItem *addUrlItem(const QString &url); @@ -66,6 +69,8 @@ private: /** @brief Refreshes the displayed label of every URL item after limits or settings change. */ void refreshUrlItems(); + + void observeSpoilerUpdater(SpoilerBackgroundUpdater *updater); }; #endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e25390ba0..5b7e06c20 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -190,6 +190,15 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/libcockatrice_network ) +cockatrice_add_test( + NAME + spoiler_file_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp + spoiler_file_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) # 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. diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp new file mode 100644 index 000000000..9944f95bd --- /dev/null +++ b/tests/spoiler_file_test.cpp @@ -0,0 +1,149 @@ +#include "client/network/update/card_spoiler/spoiler_file.h" + +#include +#include +#include +#include +#include + +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + +namespace +{ +class SpoilerFileTest : public ::testing::Test +{ +protected: + QTemporaryDir directory; + QString path; + QString error; + const QByteArray original = ""; + + void SetUp() override + { + ASSERT_TRUE(directory.isValid()); + path = directory.filePath("spoiler.xml"); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + ASSERT_EQ(file.write(original), original.size()); + } + + QByteArray read() + { + QFile file(path); + EXPECT_TRUE(file.open(QIODevice::ReadOnly)); + return file.readAll(); + } + + SpoilerFile::SaveResult save(const QByteArray &data) + { + return SpoilerFile::save(path, data, [] { return true; }, error); + } +}; + +TEST_F(SpoilerFileTest, InvalidDocumentsPreserveExistingFile) +{ + for (const QByteArray &invalid : + {QByteArray(), QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray("")}) { + EXPECT_EQ(save(invalid), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); + } +} + +TEST_F(SpoilerFileTest, SupportedVersionsAreAccepted) +{ + EXPECT_TRUE(SpoilerFile::validate(original).isEmpty()); + EXPECT_TRUE( + SpoilerFile::validate("").isEmpty()); +} + +TEST_F(SpoilerFileTest, IdenticalFileIsUnchanged) +{ + EXPECT_EQ(save(original), SpoilerFile::SaveResult::Unchanged); + EXPECT_TRUE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, CompleteFileHashDetectsChangesAfterLargeCommonPrefix) +{ + QByteArray changed = ""; + ASSERT_EQ(save(changed), SpoilerFile::SaveResult::Written); + changed.replace("", ""); + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Written); + EXPECT_EQ(read(), changed); + EXPECT_TRUE(error.isEmpty()); +} + +TEST_F(SpoilerFileTest, CancellationBeforeWritePreservesExistingFile) +{ + EXPECT_EQ(SpoilerFile::save(path, original, [] { return false; }, error), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, CancellationBeforeCommitPreservesExistingFile) +{ + int checks = 0; + EXPECT_EQ(SpoilerFile::save( + path, original + "\n", [&checks] { return ++checks == 1; }, error), + SpoilerFile::SaveResult::Failed); + EXPECT_EQ(checks, 2); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); + EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); +} + +TEST_F(SpoilerFileTest, MissingParentIsCreated) +{ + path = directory.filePath("nested/spoiler.xml"); + EXPECT_EQ(save(original), SpoilerFile::SaveResult::Written); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) +{ + EXPECT_EQ(SpoilerFile::save( + QDir(path).filePath("spoiler.xml"), original, [] { return true; }, error), + SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +#ifdef Q_OS_UNIX +TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact) +{ + // Limit only the child process: a real short write exercises QSaveFile's + // buffered flush failure without depending on disk capacity or file ownership. + const QByteArray changed = ""; + EXPECT_EXIT( + { + struct rlimit limit; + if (getrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + limit.rlim_cur = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(3); + } + std::signal(SIGXFSZ, SIG_IGN); + const auto result = save(changed); + _exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1); + }, + ::testing::ExitedWithCode(0), ""); + EXPECT_EQ(read(), original); + EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); +} +#endif +} // namespace From a23f0b43a698b3bb75dbc350b1bbfcde5e83523a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:26:50 +0100 Subject: [PATCH 234/247] [Replay] Build a compact event history index (#7611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn replay protocol events into short localized summaries with an actor, badge, and time so the history UI can render them without retaining the replay or duplicating the chat log. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/replay/replay_event_index.cpp | 876 ++++++++++++++++++ .../widgets/replay/replay_event_index.h | 43 + 3 files changed, 920 insertions(+) create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_index.cpp create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_index.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b76783167..cf195fb5f 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -252,6 +252,7 @@ set(cockatrice_SOURCES src/interface/widgets/playmat/playmat_settings_dialog.cpp src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp + src/interface/widgets/replay/replay_event_index.cpp src/interface/widgets/replay/replay_manager.cpp src/interface/widgets/replay/replay_quick_settings_widget.cpp src/interface/widgets/replay/replay_timeline_widget.cpp diff --git a/cockatrice/src/interface/widgets/replay/replay_event_index.cpp b/cockatrice/src/interface/widgets/replay/replay_event_index.cpp new file mode 100644 index 000000000..d21671b5a --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_index.cpp @@ -0,0 +1,876 @@ +#include "replay_event_index.h" + +#include "libcockatrice/protocol/get_pb_extension.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include "libcockatrice/protocol/pb/context_concede.pb.h" +#include "libcockatrice/protocol/pb/context_connection_state_changed.pb.h" +#include "libcockatrice/protocol/pb/context_deck_select.pb.h" +#include "libcockatrice/protocol/pb/context_mulligan.pb.h" +#include "libcockatrice/protocol/pb/context_ready_start.pb.h" +#include "libcockatrice/protocol/pb/context_set_sideboard_lock.pb.h" +#include "libcockatrice/protocol/pb/context_undo_draw.pb.h" +#include "libcockatrice/protocol/pb/event_attach_card.pb.h" +#include "libcockatrice/protocol/pb/event_change_zone_properties.pb.h" +#include "libcockatrice/protocol/pb/event_create_counter.pb.h" +#include "libcockatrice/protocol/pb/event_create_token.pb.h" +#include "libcockatrice/protocol/pb/event_del_counter.pb.h" +#include "libcockatrice/protocol/pb/event_destroy_card.pb.h" +#include "libcockatrice/protocol/pb/event_draw_cards.pb.h" +#include "libcockatrice/protocol/pb/event_dump_zone.pb.h" +#include "libcockatrice/protocol/pb/event_flip_card.pb.h" +#include "libcockatrice/protocol/pb/event_game_closed.pb.h" +#include "libcockatrice/protocol/pb/event_game_host_changed.pb.h" +#include "libcockatrice/protocol/pb/event_game_log_notice.pb.h" +#include "libcockatrice/protocol/pb/event_game_say.pb.h" +#include "libcockatrice/protocol/pb/event_game_state_changed.pb.h" +#include "libcockatrice/protocol/pb/event_join.pb.h" +#include "libcockatrice/protocol/pb/event_kicked.pb.h" +#include "libcockatrice/protocol/pb/event_leave.pb.h" +#include "libcockatrice/protocol/pb/event_move_card.pb.h" +#include "libcockatrice/protocol/pb/event_player_properties_changed.pb.h" +#include "libcockatrice/protocol/pb/event_reveal_cards.pb.h" +#include "libcockatrice/protocol/pb/event_reverse_turn.pb.h" +#include "libcockatrice/protocol/pb/event_roll_die.pb.h" +#include "libcockatrice/protocol/pb/event_set_active_phase.pb.h" +#include "libcockatrice/protocol/pb/event_set_active_player.pb.h" +#include "libcockatrice/protocol/pb/event_set_card_attr.pb.h" +#include "libcockatrice/protocol/pb/event_set_card_counter.pb.h" +#include "libcockatrice/protocol/pb/event_set_counter.pb.h" +#include "libcockatrice/protocol/pb/event_shuffle.pb.h" +#include "libcockatrice/protocol/pb/game_event.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/game_event_context.pb.h" +#include "libcockatrice/protocol/pb/game_replay.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_card.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_player.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" +#include "libcockatrice/utility/zone_names.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +struct PendingDetail +{ + QString text; + QString badge; + ReplayEventKind kind = ReplayEventKind::Action; + QString actor; +}; + +QString formatTime(int timeMs) +{ + const int totalSeconds = qMax(0, timeMs) / 1000; + const int hours = totalSeconds / 3600; + const int minutes = (totalSeconds / 60) % 60; + const int seconds = totalSeconds % 60; + + if (hours > 0) { + return QStringLiteral("%1:%2:%3") + .arg(hours) + .arg(minutes, 2, 10, QLatin1Char('0')) + .arg(seconds, 2, 10, QLatin1Char('0')); + } + + return QStringLiteral("%1:%2").arg(minutes, 2, 10, QLatin1Char('0')).arg(seconds, 2, 10, QLatin1Char('0')); +} + +class ReplayEventIndexBuilder +{ + Q_DECLARE_TR_FUNCTIONS(ReplayEventIndex) + +public: + ReplayEventIndexBuilder(const GameReplay *replay, const QList &timeline) : replay(replay), timeline(timeline) + { + } + + QList build() + { + QList entries; + + for (int i = 0; i < replay->event_list_size(); ++i) { + const GameEventContainer &container = replay->event_list(i); + const QList details = describeContainer(container); + if (details.isEmpty()) { + continue; + } + + ReplayEventEntry entry; + entry.eventIndex = i; + entry.timeMs = timeline.value(i, static_cast(container.seconds_elapsed()) * 1000); + entry.timeLabel = formatTime(entry.timeMs); + entry.kind = details.first().kind; + entry.badge = details.first().badge; + if (details.size() > 1) { + entry.badge += QStringLiteral(" +%1").arg(details.size() - 1); + } + + const QString sharedActor = details.first().actor; + const bool mixedActors = std::any_of(details.cbegin(), details.cend(), [&sharedActor](const auto &detail) { + return detail.actor != sharedActor; + }); + + QStringList detailTexts; + for (const PendingDetail &detail : details) { + if (mixedActors && !detail.actor.isEmpty()) { + detailTexts.append(tr("%1: %2").arg(detail.actor, detail.text)); + } else { + detailTexts.append(detail.text); + } + } + entry.actor = mixedActors ? QString() : sharedActor; + entry.detail = detailTexts.join(QStringLiteral(" • ")); + + entries.append(entry); + } + + return entries; + } + +private: + const GameReplay *replay; + const QList &timeline; + + QHash playerNames; + QHash spectators; + QHash cardNames; + QHash counterNames; + + int activePlayerId = -1; + bool gameStarted = false; + bool sawGameState = false; + + static QString cardKey(int playerId, const QString &zone, int cardId) + { + return QStringLiteral("%1/%2/%3").arg(playerId).arg(zone).arg(cardId); + } + + static QString counterKey(int playerId, int counterId) + { + return QStringLiteral("%1/%2").arg(playerId).arg(counterId); + } + + QString playerName(int playerId) const + { + if (playerId < 0) { + return tr("Game"); + } + return playerNames.value(playerId, tr("Player %1").arg(playerId)); + } + + QString actorName(int playerId) const + { + if (playerId < 0) { + return QString(); + } + return playerNames.value(playerId, tr("Player %1").arg(playerId)); + } + + bool isSpectator(int playerId) const + { + return spectators.value(playerId, false); + } + + void rememberPlayer(const ServerInfo_PlayerProperties &properties) + { + const int playerId = properties.player_id(); + if (playerId < 0) { + return; + } + + const QString name = QString::fromStdString(properties.user_info().name()); + if (!name.isEmpty()) { + playerNames.insert(playerId, name); + } + spectators.insert(playerId, properties.spectator()); + } + + void rememberCard(int playerId, const QString &zone, int cardId, const QString &name) + { + if (cardId >= 0 && !name.isEmpty()) { + cardNames.insert(cardKey(playerId, zone, cardId), name); + } + } + + void forgetCard(int playerId, const QString &zone, int cardId) + { + if (cardId >= 0) { + cardNames.remove(cardKey(playerId, zone, cardId)); + } + } + + QString cardName(int playerId, const QString &zone, int cardId) const + { + if (cardId < 0) { + return QString(); + } + return cardNames.value(cardKey(playerId, zone, cardId)); + } + + void rememberCounter(int playerId, const ServerInfo_Counter &counter) + { + const QString name = QString::fromStdString(counter.name()); + if (!name.isEmpty()) { + counterNames.insert(counterKey(playerId, counter.id()), name); + } + } + + QString counterName(int playerId, int counterId) const + { + return counterNames.value(counterKey(playerId, counterId)); + } + + QString zoneName(const QString &zone) const + { + if (zone == ZoneNames::TABLE) { + return tr("battlefield"); + } + if (zone == ZoneNames::GRAVE) { + return tr("graveyard"); + } + if (zone == ZoneNames::EXILE) { + return tr("exile"); + } + if (zone == ZoneNames::HAND) { + return tr("hand"); + } + if (zone == ZoneNames::DECK) { + return tr("library"); + } + if (zone == ZoneNames::SIDEBOARD) { + return tr("sideboard"); + } + if (zone == ZoneNames::STACK) { + return tr("stack"); + } + return zone; + } + + QString zonePhrase(int playerId, const QString &zone, int actorId) const + { + if (zone == ZoneNames::TABLE) { + return tr("the battlefield"); + } + if (zone == ZoneNames::EXILE) { + return tr("exile"); + } + if (zone == ZoneNames::STACK) { + return tr("the stack"); + } + + const QString name = zoneName(zone); + if (playerId < 0 || playerId == actorId) { + return tr("their %1").arg(name); + } + return tr("%1's %2").arg(playerName(playerId), name); + } + + QString formatCardList(const QStringList &names, int total) const + { + // Keep repeats: each entry is a drawn/revealed card, so the list length must agree with the count. + QStringList listedNames; + for (const QString &name : names) { + if (!name.isEmpty()) { + listedNames.append(name); + } + } + + if (listedNames.isEmpty()) { + if (total <= 0) { + return tr("no cards"); + } + return total == 1 ? tr("1 card") : tr("%1 cards").arg(total); + } + + if (total < listedNames.size()) { + total = listedNames.size(); + } + + if (listedNames.size() > 3) { + return tr("%1 and %2 more").arg(listedNames.mid(0, 3).join(QStringLiteral(", "))).arg(total - 3); + } + if (total > listedNames.size()) { + return tr("%1 and %2 more").arg(listedNames.join(QStringLiteral(", "))).arg(total - listedNames.size()); + } + + return listedNames.join(QStringLiteral(", ")); + } + + void updateGameState(const Event_GameStateChanged &event) + { + for (int i = 0; i < event.player_list_size(); ++i) { + const ServerInfo_Player &player = event.player_list(i); + const int playerId = player.properties().player_id(); + rememberPlayer(player.properties()); + + for (int j = 0; j < player.zone_list_size(); ++j) { + const ServerInfo_Zone &zone = player.zone_list(j); + for (int k = 0; k < zone.card_list_size(); ++k) { + const ServerInfo_Card &card = zone.card_list(k); + rememberCard(playerId, QString::fromStdString(zone.name()), card.id(), + QString::fromStdString(card.name())); + } + } + + for (int j = 0; j < player.counter_list_size(); ++j) { + rememberCounter(playerId, player.counter_list(j)); + } + } + + if (event.has_active_player_id()) { + activePlayerId = event.active_player_id(); + } + if (event.has_game_started()) { + gameStarted = event.game_started(); + } + } + + QString describeMoveCard(const Event_MoveCard &event, int actorId, const GameEventContext &context) + { + const int startPlayerId = event.start_player_id() >= 0 ? event.start_player_id() : actorId; + const int targetPlayerId = event.target_player_id() >= 0 ? event.target_player_id() : startPlayerId; + const QString startZone = QString::fromStdString(event.start_zone()); + const QString targetZone = event.has_target_zone() ? QString::fromStdString(event.target_zone()) : startZone; + + QString name; + if (event.has_card_name()) { + name = QString::fromStdString(event.card_name()); + } + if (name.isEmpty()) { + name = cardName(startPlayerId, startZone, event.card_id()); + } + + const bool knownName = !name.isEmpty(); + if (name.isEmpty()) { + name = event.face_down() ? tr("a face-down card") : tr("a card"); + } + + const int newCardId = event.has_new_card_id() ? event.new_card_id() : event.card_id(); + forgetCard(startPlayerId, startZone, event.card_id()); + if (knownName) { + rememberCard(targetPlayerId, targetZone, newCardId, name); + } + + if (startZone == targetZone) { + return tr("moved %1 within %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId)); + } + if (context.HasExtension(Context_UndoDraw::ext)) { + return tr("returned %1 to %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId)); + } + + return tr("moved %1 from %2 to %3.") + .arg(name, zonePhrase(startPlayerId, startZone, actorId), zonePhrase(targetPlayerId, targetZone, actorId)); + } + + QString describeSetCardAttribute(const Event_SetCardAttr &event, int actorId) + { + const QString zone = QString::fromStdString(event.zone_name()); + QString name; + if (event.has_card_id()) { + name = cardName(actorId, zone, event.card_id()); + } + if (name.isEmpty()) { + name = event.has_card_id() ? tr("a card") : tr("all cards"); + } + + const QString value = QString::fromStdString(event.attr_value()); + switch (event.attribute()) { + case AttrTapped: + return value == QLatin1String("1") ? tr("tapped %1.").arg(name) : tr("untapped %1.").arg(name); + case AttrAttacking: + return value == QLatin1String("1") ? tr("%1 is now attacking.").arg(name) + : tr("%1 is no longer attacking.").arg(name); + case AttrFaceDown: + return value == QLatin1String("1") ? tr("turned %1 face down.").arg(name) + : tr("turned %1 face up.").arg(name); + case AttrColor: + return tr("changed %1's color to %2.").arg(name, value); + case AttrPT: + return tr("set %1's power/toughness to %2.").arg(name, value); + case AttrAnnotation: + return value.isEmpty() ? tr("cleared %1's annotation.").arg(name) + : tr("annotated %1: %2").arg(name, value); + case AttrDoesntUntap: + return value == QLatin1String("1") ? tr("%1 will no longer untap.").arg(name) + : tr("%1 will untap normally again.").arg(name); + } + + return tr("changed %1.").arg(name); + } + + QString badgeForCardAttribute(CardAttribute attribute) const + { + switch (attribute) { + case AttrTapped: + return tr("TAP"); + case AttrAttacking: + return tr("ATTACK"); + case AttrFaceDown: + return tr("FLIP"); + case AttrColor: + return tr("COLOR"); + case AttrPT: + return tr("PT"); + case AttrAnnotation: + return tr("NOTE"); + case AttrDoesntUntap: + return tr("TAP"); + } + return tr("CARD"); + } + + QString describeLeaveReason(Event_Leave::LeaveReason reason) const + { + switch (reason) { + case Event_Leave::USER_KICKED: + return tr("kicked by game host or moderator"); + case Event_Leave::USER_LEFT: + return tr("left the game"); + case Event_Leave::USER_DISCONNECTED: + return tr("disconnected"); + case Event_Leave::OTHER: + default: + return tr("reason unknown"); + } + } + + void addDetail(QList &details, + const QString &text, + const QString &badge, + ReplayEventKind kind, + int actorId, + bool forcedByJudge) const + { + if (text.isEmpty()) { + return; + } + + const QString finalText = forcedByJudge ? tr("%1 (judge)").arg(text) : text; + details.append(PendingDetail{.text = finalText, .badge = badge, .kind = kind, .actor = actorName(actorId)}); + } + + QList describeContainer(const GameEventContainer &container) + { + QList details; + const GameEventContext &context = container.context(); + const bool forcedByJudge = container.has_forced_by_judge(); + const auto contextType = static_cast(getPbExtension(context)); + + for (int i = 0; i < container.event_list_size(); ++i) { + const GameEvent &event = container.event_list(i); + const int eventPlayerId = event.player_id(); + const auto eventType = static_cast(getPbExtension(event)); + + switch (eventType) { + case GameEvent::GAME_STATE_CHANGED: { + const Event_GameStateChanged &state = event.GetExtension(Event_GameStateChanged::ext); + const bool wasStarted = gameStarted; + updateGameState(state); + if (state.game_started() && !wasStarted) { + addDetail(details, tr("Game started."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge); + } else if (!sawGameState) { + addDetail(details, tr("Game state loaded."), tr("GAME"), ReplayEventKind::System, -1, + forcedByJudge); + } + sawGameState = true; + break; + } + case GameEvent::PLAYER_PROPERTIES_CHANGED: { + const Event_PlayerPropertiesChanged &propertiesEvent = + event.GetExtension(Event_PlayerPropertiesChanged::ext); + const bool hasProperties = propertiesEvent.has_player_properties(); + const ServerInfo_PlayerProperties &properties = propertiesEvent.player_properties(); + // The server only fills in the field that changed, so player_id is absent from these events + // unless they are about a specific player; fall back to the event's own player id. + const bool describesPlayer = hasProperties && properties.has_player_id(); + const int playerId = describesPlayer ? properties.player_id() : eventPlayerId; + if (describesPlayer) { + rememberPlayer(properties); + } + + switch (contextType) { + case GameEventContext::READY_START: + addDetail(details, + properties.ready_start() ? tr("is ready to start.") : tr("is no longer ready."), + tr("READY"), ReplayEventKind::System, playerId, forcedByJudge); + break; + case GameEventContext::CONCEDE: + addDetail(details, tr("conceded the game."), tr("CONCEDE"), ReplayEventKind::System, + playerId, forcedByJudge); + break; + case GameEventContext::UNCONCEDE: + addDetail(details, tr("unconceded the game."), tr("CONCEDE"), ReplayEventKind::System, + playerId, forcedByJudge); + break; + case GameEventContext::DECK_SELECT: { + const Context_DeckSelect &deckSelect = context.GetExtension(Context_DeckSelect::ext); + const int sideboardSize = deckSelect.sideboard_size(); + addDetail(details, + sideboardSize >= 0 + ? tr("selected a deck with %1 sideboard cards.").arg(sideboardSize) + : tr("selected a deck."), + tr("DECK"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEventContext::MULLIGAN: { + const Context_Mulligan &mulligan = context.GetExtension(Context_Mulligan::ext); + addDetail(details, + mulligan.number() > 0 ? tr("took a mulligan (%1).").arg(mulligan.number()) + : tr("took a mulligan."), + tr("MULLIGAN"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEventContext::SET_SIDEBOARD_LOCK: + addDetail(details, + properties.sideboard_locked() ? tr("locked their sideboard.") + : tr("unlocked their sideboard."), + tr("SIDEBOARD"), ReplayEventKind::System, playerId, forcedByJudge); + break; + case GameEventContext::CONNECTION_STATE_CHANGED: + addDetail(details, + properties.ping_seconds() != -1 ? tr("reconnected to the game.") + : tr("lost connection to the game."), + tr("CONNECTION"), ReplayEventKind::System, playerId, forcedByJudge); + break; + default: + break; + } + break; + } + case GameEvent::JOIN: { + const Event_Join &join = event.GetExtension(Event_Join::ext); + if (join.has_player_properties()) { + rememberPlayer(join.player_properties()); + } + const int playerId = join.player_properties().player_id() >= 0 + ? join.player_properties().player_id() + : eventPlayerId; + addDetail(details, isSpectator(playerId) ? tr("joined as a spectator.") : tr("joined the game."), + tr("JOIN"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEvent::LEAVE: { + const Event_Leave &leave = event.GetExtension(Event_Leave::ext); + const QString reason = describeLeaveReason(leave.reason()); + if (eventPlayerId < 0) { + addDetail(details, tr("A player left the game (%1).").arg(reason), tr("LEAVE"), + ReplayEventKind::System, -1, forcedByJudge); + } else { + addDetail(details, + isSpectator(eventPlayerId) ? tr("stopped spectating (%1).").arg(reason) + : tr("left the game (%1).").arg(reason), + tr("LEAVE"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::KICKED: + addDetail(details, + eventPlayerId >= 0 ? tr("was kicked from the game.") + : tr("A player was kicked from the game."), + tr("KICK"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + break; + case GameEvent::GAME_CLOSED: + addDetail(details, tr("Game closed."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge); + break; + case GameEvent::GAME_HOST_CHANGED: + addDetail(details, eventPlayerId >= 0 ? tr("is now the game host.") : tr("The game host changed."), + tr("HOST"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + break; + case GameEvent::GAME_SAY: { + const Event_GameSay &say = event.GetExtension(Event_GameSay::ext); + addDetail(details, QString::fromStdString(say.message()), tr("CHAT"), ReplayEventKind::Chat, + eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_ACTIVE_PLAYER: { + const Event_SetActivePlayer &setActivePlayer = event.GetExtension(Event_SetActivePlayer::ext); + activePlayerId = setActivePlayer.active_player_id(); + if (activePlayerId >= 0) { + addDetail(details, tr("begins their turn."), tr("TURN"), ReplayEventKind::Turn, activePlayerId, + forcedByJudge); + } + break; + } + case GameEvent::SET_ACTIVE_PHASE: { + const Event_SetActivePhase &setActivePhase = event.GetExtension(Event_SetActivePhase::ext); + if (setActivePhase.phase() >= 0) { + const QString phaseName = Phases::getPhase(setActivePhase.phase()).getName(); + addDetail(details, tr("entered the %1 phase.").arg(phaseName), tr("PHASE"), + ReplayEventKind::Phase, activePlayerId >= 0 ? activePlayerId : eventPlayerId, + forcedByJudge); + } + break; + } + case GameEvent::REVERSE_TURN: { + const Event_ReverseTurn &reverseTurn = event.GetExtension(Event_ReverseTurn::ext); + addDetail(details, + reverseTurn.reversed() ? tr("reversed the turn order.") : tr("restored the turn order."), + tr("TURN"), ReplayEventKind::Turn, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::MOVE_CARD: { + const Event_MoveCard &moveCard = event.GetExtension(Event_MoveCard::ext); + const QString text = describeMoveCard(moveCard, eventPlayerId, context); + const QString startZone = QString::fromStdString(moveCard.start_zone()); + const QString targetZone = QString::fromStdString(moveCard.target_zone()); + QString badge = tr("MOVE"); + if (context.HasExtension(Context_UndoDraw::ext)) { + badge = tr("UNDO"); + } else if (startZone == ZoneNames::STACK) { + badge = tr("RESOLVE"); + } else if (startZone == ZoneNames::HAND && targetZone == ZoneNames::TABLE) { + badge = tr("PLAY"); + } + addDetail(details, text, badge, ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DRAW_CARDS: { + const Event_DrawCards &drawCards = event.GetExtension(Event_DrawCards::ext); + QStringList names; + for (int j = 0; j < drawCards.cards_size(); ++j) { + const ServerInfo_Card &card = drawCards.cards(j); + const QString name = QString::fromStdString(card.name()); + names.append(name); + rememberCard(eventPlayerId, ZoneNames::HAND, card.id(), name); + } + if (drawCards.number() > 0) { + addDetail(details, tr("drew %1.").arg(formatCardList(names, drawCards.number())), tr("DRAW"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::REVEAL_CARDS: { + const Event_RevealCards &revealCards = event.GetExtension(Event_RevealCards::ext); + const QString zone = QString::fromStdString(revealCards.zone_name()); + QStringList names; + for (int j = 0; j < revealCards.cards_size(); ++j) { + const ServerInfo_Card &card = revealCards.cards(j); + const QString name = QString::fromStdString(card.name()); + names.append(name); + rememberCard(eventPlayerId, zone, card.id(), name); + } + const int total = + revealCards.number_of_cards() > 0 ? revealCards.number_of_cards() : revealCards.cards_size(); + QString text = + tr("revealed %1 from %2.") + .arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId)); + if (revealCards.other_player_id() >= 0 && revealCards.other_player_id() != eventPlayerId) { + text = tr("revealed %1 from %2 to %3.") + .arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId), + playerName(revealCards.other_player_id())); + } + addDetail(details, text, tr("REVEAL"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SHUFFLE: { + const Event_Shuffle &shuffle = event.GetExtension(Event_Shuffle::ext); + addDetail( + details, + tr("shuffled %1.") + .arg(zonePhrase(eventPlayerId, QString::fromStdString(shuffle.zone_name()), eventPlayerId)), + tr("SHUFFLE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::ROLL_DIE: { + const Event_RollDie &rollDie = event.GetExtension(Event_RollDie::ext); + QStringList rolls; + for (int j = 0; j < rollDie.values_size(); ++j) { + rolls.append(QString::number(rollDie.values(j))); + } + if (rolls.isEmpty() && rollDie.value() > 0) { + rolls.append(QString::number(rollDie.value())); + } + const QString sides = rollDie.sides() > 0 ? QString::number(rollDie.sides()) : QStringLiteral("?"); + addDetail(details, tr("rolled a d%1: %2.").arg(sides, rolls.join(QStringLiteral(", "))), tr("DICE"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::FLIP_CARD: { + const Event_FlipCard &flipCard = event.GetExtension(Event_FlipCard::ext); + const QString zone = QString::fromStdString(flipCard.zone_name()); + if (flipCard.has_card_name()) { + rememberCard(eventPlayerId, zone, flipCard.card_id(), + QString::fromStdString(flipCard.card_name())); + } + QString name = flipCard.has_card_name() ? QString::fromStdString(flipCard.card_name()) + : cardName(eventPlayerId, zone, flipCard.card_id()); + if (name.isEmpty()) { + name = tr("a card"); + } + addDetail(details, + flipCard.face_down() ? tr("flipped %1 face down.").arg(name) + : tr("flipped %1 face up.").arg(name), + tr("FLIP"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DESTROY_CARD: { + const Event_DestroyCard &destroyCard = event.GetExtension(Event_DestroyCard::ext); + const QString zone = QString::fromStdString(destroyCard.zone_name()); + const QString name = cardName(eventPlayerId, zone, destroyCard.card_id()); + forgetCard(eventPlayerId, zone, destroyCard.card_id()); + addDetail(details, tr("destroyed %1.").arg(name.isEmpty() ? tr("a card") : name), tr("DESTROY"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::ATTACH_CARD: { + const Event_AttachCard &attachCard = event.GetExtension(Event_AttachCard::ext); + const QString startZone = QString::fromStdString(attachCard.start_zone()); + const QString source = cardName(eventPlayerId, startZone, attachCard.card_id()); + const QString sourceText = source.isEmpty() ? tr("a card") : source; + // An unattach event carries no target at all, so the target fields default to player 0 / no zone + // / card 0; only a present target_player_id means the card was attached to something. + if (!attachCard.has_target_player_id()) { + addDetail(details, tr("unattached %1.").arg(sourceText), tr("ATTACH"), ReplayEventKind::Action, + eventPlayerId, forcedByJudge); + break; + } + const QString targetZone = QString::fromStdString(attachCard.target_zone()); + const int targetPlayerId = + attachCard.target_player_id() >= 0 ? attachCard.target_player_id() : eventPlayerId; + const QString target = cardName(targetPlayerId, targetZone, attachCard.target_card_id()); + addDetail(details, + tr("attached %1 to %2.").arg(sourceText, target.isEmpty() ? tr("a card") : target), + tr("ATTACH"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::CREATE_TOKEN: { + const Event_CreateToken &createToken = event.GetExtension(Event_CreateToken::ext); + const QString name = QString::fromStdString(createToken.card_name()); + rememberCard(eventPlayerId, QString::fromStdString(createToken.zone_name()), createToken.card_id(), + name); + const QString pt = QString::fromStdString(createToken.pt()); + addDetail( + details, + tr("created a token: %1%2.") + .arg(name.isEmpty() ? tr("token") : name, pt.isEmpty() ? QString() : tr(" (%1)").arg(pt)), + tr("TOKEN"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_CARD_ATTR: { + const Event_SetCardAttr &setCardAttr = event.GetExtension(Event_SetCardAttr::ext); + addDetail(details, describeSetCardAttribute(setCardAttr, eventPlayerId), + badgeForCardAttribute(setCardAttr.attribute()), ReplayEventKind::Action, eventPlayerId, + forcedByJudge); + break; + } + case GameEvent::SET_CARD_COUNTER: { + const Event_SetCardCounter &setCardCounter = event.GetExtension(Event_SetCardCounter::ext); + const QString name = cardName(eventPlayerId, QString::fromStdString(setCardCounter.zone_name()), + setCardCounter.card_id()); + addDetail( + details, + tr("set a counter on %1 to %2.") + .arg(name.isEmpty() ? tr("a card") : name, QString::number(setCardCounter.counter_value())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::CREATE_COUNTER: { + const Event_CreateCounter &createCounter = event.GetExtension(Event_CreateCounter::ext); + const ServerInfo_Counter &counter = createCounter.counter_info(); + rememberCounter(eventPlayerId, counter); + const QString name = QString::fromStdString(counter.name()); + addDetail(details, + tr("created the %1 counter (%2).") + .arg(name.isEmpty() ? tr("new") : name, QString::number(counter.count())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_COUNTER: { + const Event_SetCounter &setCounter = event.GetExtension(Event_SetCounter::ext); + const QString name = counterName(eventPlayerId, setCounter.counter_id()); + addDetail(details, + tr("set %1 to %2.") + .arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name), + QString::number(setCounter.value())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DEL_COUNTER: { + const Event_DelCounter &delCounter = event.GetExtension(Event_DelCounter::ext); + const QString name = counterName(eventPlayerId, delCounter.counter_id()); + counterNames.remove(counterKey(eventPlayerId, delCounter.counter_id())); + addDetail(details, + tr("removed %1.").arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name)), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DUMP_ZONE: { + const Event_DumpZone &dumpZone = event.GetExtension(Event_DumpZone::ext); + const QString zoneOwnerPhrase = zonePhrase( + dumpZone.zone_owner_id(), QString::fromStdString(dumpZone.zone_name()), eventPlayerId); + if (dumpZone.number_cards() < 0) { + addDetail(details, tr("looked at %1.").arg(zoneOwnerPhrase), tr("LOOK"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } else { + addDetail(details, + tr("looked at %1 from %2.") + .arg(formatCardList(QStringList(), dumpZone.number_cards()), zoneOwnerPhrase), + tr("LOOK"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::CHANGE_ZONE_PROPERTIES: { + const Event_ChangeZoneProperties &changeZone = event.GetExtension(Event_ChangeZoneProperties::ext); + const QString zone = QString::fromStdString(changeZone.zone_name()); + QStringList changes; + if (changeZone.always_reveal_top_card()) { + changes.append(tr("top card revealed to all")); + } + if (changeZone.always_look_at_top_card()) { + changes.append(tr("top card visible to its owner")); + } + addDetail( + details, + changes.isEmpty() + ? tr("changed the properties of %1.").arg(zonePhrase(eventPlayerId, zone, eventPlayerId)) + : tr("changed %1: %2.") + .arg(zonePhrase(eventPlayerId, zone, eventPlayerId), + changes.join(QStringLiteral(", "))), + tr("ZONE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::GAME_LOG_NOTICE: { + const Event_GameLogNotice ¬ice = event.GetExtension(Event_GameLogNotice::ext); + switch (notice.notice_type()) { + case Event_GameLogNotice::UNDO_DRAW_FAILED: + addDetail(details, tr("failed to undo a draw."), tr("NOTICE"), ReplayEventKind::System, + eventPlayerId, forcedByJudge); + break; + default: + addDetail(details, tr("A game notice was logged."), tr("NOTICE"), ReplayEventKind::System, + -1, forcedByJudge); + break; + } + break; + } + case GameEvent::CREATE_ARROW: + case GameEvent::DELETE_ARROW: + // Arrows are transient visual annotations and would make the history very noisy. + break; + default: + addDetail(details, tr("Unrecognized game event."), tr("EVENT"), ReplayEventKind::Action, + eventPlayerId, forcedByJudge); + break; + } + } + + return details; + } +}; +} // namespace + +QList buildReplayEventEntries(const GameReplay *replay, const QList &timeline) +{ + if (!replay) { + return {}; + } + + return ReplayEventIndexBuilder(replay, timeline).build(); +} diff --git a/cockatrice/src/interface/widgets/replay/replay_event_index.h b/cockatrice/src/interface/widgets/replay/replay_event_index.h new file mode 100644 index 000000000..40f72a965 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_index.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_REPLAY_EVENT_INDEX_H +#define COCKATRICE_REPLAY_EVENT_INDEX_H + +#include +#include + +class GameReplay; + +enum class ReplayEventKind +{ + Action, + Turn, + Phase, + Chat, + System +}; + +/** + * @brief One user-visible entry in the replay history sidebar. + * + * The event index refers to the corresponding GameEventContainer in the replay. It is used to seek the replay to that + * container. + */ +struct ReplayEventEntry +{ + int eventIndex = -1; + int timeMs = 0; + QString timeLabel; + QString actor; + QString badge; + QString detail; + ReplayEventKind kind = ReplayEventKind::Action; +}; + +/** + * @brief Builds a compact, human-readable index of the events in a replay. + * + * The returned entries copy all the data they need, so the caller does not need to keep the replay object alive after + * this function returns. + */ +QList buildReplayEventEntries(const GameReplay *replay, const QList &timeline); + +#endif // COCKATRICE_REPLAY_EVENT_INDEX_H From fc27cb7a59266f63463d3fd54b6d0fe25fc92c96 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:26:50 +0100 Subject: [PATCH 235/247] [Replay] Add a clickable replay history sidebar (#7612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the event index as a scrollable list of event boxes. Selecting an entry seeks the replay to that event and keeps the current event highlighted while playback advances. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/replay/replay_event_sidebar.cpp | 284 ++++++++++++++++++ .../widgets/replay/replay_event_sidebar.h | 50 +++ 3 files changed, 335 insertions(+) create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_sidebar.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index cf195fb5f..e99735bf7 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -253,6 +253,7 @@ set(cockatrice_SOURCES src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp src/interface/widgets/replay/replay_event_index.cpp + src/interface/widgets/replay/replay_event_sidebar.cpp src/interface/widgets/replay/replay_manager.cpp src/interface/widgets/replay/replay_quick_settings_widget.cpp src/interface/widgets/replay/replay_timeline_widget.cpp diff --git a/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp new file mode 100644 index 000000000..b5fab2794 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp @@ -0,0 +1,284 @@ +#include "replay_event_sidebar.h" + +#include "replay_event_index.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +enum ItemDataRole +{ + EventIndexRole = Qt::UserRole + 1, + TimeLabelRole, + KindRole, + ActorRole, + BadgeRole, + DetailRole +}; + +QColor accentForKind(ReplayEventKind kind, const QPalette &palette) +{ + switch (kind) { + case ReplayEventKind::Turn: + return QColor(0x4c, 0xaf, 0x50); + case ReplayEventKind::Phase: + return QColor(0x42, 0xa5, 0xf5); + case ReplayEventKind::Chat: + return QColor(0xff, 0xb3, 0x00); + case ReplayEventKind::System: + return palette.mid().color(); + case ReplayEventKind::Action: + default: + return palette.highlight().color(); + } +} + +class ReplayEventDelegate : public QStyledItemDelegate +{ +public: + explicit ReplayEventDelegate(QObject *parent) : QStyledItemDelegate(parent) + { + } + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + const QPalette &palette = option.palette; + const bool selected = option.state.testFlag(QStyle::State_Selected); + const bool hovered = option.state.testFlag(QStyle::State_MouseOver); + const auto kind = static_cast(index.data(KindRole).toInt()); + const QColor accent = accentForKind(kind, palette); + + const QRect card = option.rect.adjusted(4, 2, -4, -2); + QColor cardBackground = selected ? palette.highlight().color() : palette.base().color(); + if (!selected && hovered) { + cardBackground = palette.alternateBase().color(); + } + + painter->setPen(Qt::NoPen); + painter->setBrush(cardBackground); + painter->drawRoundedRect(card, 6, 6); + + QColor borderColor = selected ? palette.highlightedText().color() : palette.mid().color(); + borderColor.setAlpha(selected ? 100 : 130); + painter->setBrush(Qt::NoBrush); + painter->setPen(QPen(borderColor, 1)); + painter->drawRoundedRect(card.adjusted(0, 0, -1, -1), 6, 6); + + QColor accentColor = accent; + if (selected) { + accentColor = palette.highlightedText().color(); + accentColor.setAlpha(210); + } + painter->setPen(Qt::NoPen); + painter->setBrush(accentColor); + painter->drawRoundedRect(QRect(card.left() + 7, card.top() + 7, 3, card.height() - 14), 1.5, 1.5); + + const QColor textColor = selected ? palette.highlightedText().color() : palette.text().color(); + QColor mutedColor = textColor; + mutedColor.setAlpha(170); + + QFont timeFont = option.font; + timeFont.setBold(true); + timeFont.setPointSizeF(qMax(7.0, timeFont.pointSizeF() * 0.9)); + + QFont actorFont = option.font; + actorFont.setBold(true); + + QFont badgeFont = option.font; + badgeFont.setBold(true); + badgeFont.setPointSizeF(qMax(6.5, badgeFont.pointSizeF() * 0.72)); + + QFont detailFont = option.font; + detailFont.setPointSizeF(qMax(7.0, detailFont.pointSizeF() * 0.95)); + + const QFontMetrics timeMetrics(timeFont); + const QFontMetrics actorMetrics(actorFont); + const QFontMetrics badgeMetrics(badgeFont); + const QFontMetrics detailMetrics(detailFont); + + const int contentLeft = card.left() + 15; + const int contentRight = card.right() - 10; + const int topRowTop = card.top() + 6; + const int topRowHeight = qMax(timeMetrics.height(), badgeMetrics.height()) + 2; + + const QString timeText = index.data(TimeLabelRole).toString(); + const QString actorText = index.data(ActorRole).toString(); + const QString badgeText = index.data(BadgeRole).toString(); + const QString detailText = index.data(DetailRole).toString(); + + const int timeWidth = timeMetrics.horizontalAdvance(timeText); + painter->setFont(timeFont); + painter->setPen(mutedColor); + painter->drawText(QRect(contentLeft, topRowTop, timeWidth + 2, topRowHeight), Qt::AlignLeft | Qt::AlignVCenter, + timeText); + + int actorRight = contentRight; + if (!badgeText.isEmpty()) { + const int badgeWidth = badgeMetrics.horizontalAdvance(badgeText) + 10; + const int badgeHeight = badgeMetrics.height() + 2; + const QRect badgeRect(contentRight - badgeWidth, topRowTop + (topRowHeight - badgeHeight) / 2, badgeWidth, + badgeHeight); + + QColor badgeBackground = accentColor; + badgeBackground.setAlpha(selected ? 45 : 32); + painter->setPen(Qt::NoPen); + painter->setBrush(badgeBackground); + painter->drawRoundedRect(badgeRect, 3, 3); + + painter->setFont(badgeFont); + painter->setPen(accentColor); + painter->drawText(badgeRect, Qt::AlignCenter, badgeText); + + actorRight = badgeRect.left() - 8; + } + + const int actorLeft = contentLeft + timeWidth + 10; + if (actorRight > actorLeft) { + const QString elidedActor = + actorMetrics.elidedText(actorText, Qt::ElideRight, qMax(0, actorRight - actorLeft)); + painter->setFont(actorFont); + painter->setPen(textColor); + painter->drawText(QRect(actorLeft, topRowTop, actorRight - actorLeft, topRowHeight), + Qt::AlignLeft | Qt::AlignVCenter, elidedActor); + } + + const int detailTop = topRowTop + topRowHeight + 2; + const int detailHeight = qMax(detailMetrics.height(), card.bottom() - detailTop); + const QString elidedDetail = + detailMetrics.elidedText(detailText, Qt::ElideRight, qMax(0, contentRight - contentLeft)); + painter->setFont(detailFont); + painter->setPen(textColor); + painter->drawText(QRect(contentLeft, detailTop, contentRight - contentLeft, detailHeight), + Qt::AlignLeft | Qt::AlignVCenter, elidedDetail); + + painter->restore(); + } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex & /*index*/) const override + { + const QFontMetrics metrics(option.font); + return {0, qMax(58, metrics.height() * 3 + 10)}; + } +}; +} // namespace + +ReplayEventSidebar::ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList &timeline) + : QWidget(parent), eventList(new QListWidget(this)), emptyLabel(new QLabel(this)) +{ + setObjectName("replayEventSidebar"); + + eventList->setObjectName("replayEventList"); + eventList->setItemDelegate(new ReplayEventDelegate(eventList)); + eventList->setFrameShape(QFrame::NoFrame); + eventList->setFocusPolicy(Qt::NoFocus); + eventList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + eventList->setSelectionMode(QAbstractItemView::SingleSelection); + eventList->setMouseTracking(true); + eventList->setUniformItemSizes(true); + eventList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setWordWrap(true); + emptyLabel->setMargin(16); + emptyLabel->setForegroundRole(QPalette::PlaceholderText); + + auto *layout = QtUtils::makeLayout(this); + layout->addWidget(eventList); + layout->addWidget(emptyLabel); + + const QList entries = buildReplayEventEntries(replay, timeline); + eventIndices.reserve(entries.size()); + + for (const ReplayEventEntry &entry : entries) { + auto *item = new QListWidgetItem(eventList); + item->setData(EventIndexRole, entry.eventIndex); + item->setData(TimeLabelRole, entry.timeLabel); + item->setData(KindRole, static_cast(entry.kind)); + item->setData(ActorRole, entry.actor); + item->setData(BadgeRole, entry.badge); + item->setData(DetailRole, entry.detail); + const QString tooltip = entry.actor.isEmpty() + ? tr("%1\n%2").arg(entry.timeLabel, entry.detail) + : tr("%1 · %2\n%3").arg(entry.timeLabel, entry.actor, entry.detail); + item->setToolTip(tooltip); + item->setData(Qt::AccessibleTextRole, entry.actor.isEmpty() + ? tr("%1: %2").arg(entry.timeLabel, entry.detail) + : tr("%1, %2: %3").arg(entry.timeLabel, entry.actor, entry.detail)); + eventIndices.append(entry.eventIndex); + } + + const bool hasEvents = !entries.isEmpty(); + eventList->setVisible(hasEvents); + emptyLabel->setVisible(!hasEvents); + emptyLabel->setText(tr("No game events in this replay.")); + + connect(eventList, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) { + if (!item) { + return; + } + + setCurrentRow(eventList->row(item)); + emit eventSelected(item->data(EventIndexRole).toInt()); + }); + + if (hasEvents) { + setCurrentRow(-1); + } +} + +void ReplayEventSidebar::setCurrentEvent(int eventIndex) +{ + if (eventIndices.isEmpty()) { + setCurrentRow(-1); + return; + } + + // The rows hold a subset of the replay's events, in playback order, so the last row at or before eventIndex is the + // one the game state has reached. An eventIndex of -1 (nothing replayed yet) selects no row. + const auto it = std::upper_bound(eventIndices.cbegin(), eventIndices.cend(), eventIndex); + const int row = static_cast(it - eventIndices.cbegin()) - 1; + setCurrentRow(row); +} + +void ReplayEventSidebar::setCurrentRow(int row) +{ + if (row < 0 || row >= eventList->count()) { + row = -1; + } + + if (row >= 0) { + // The view's current index also moves on mouse press and can be deselected by a ctrl-click, + // so the cached row alone cannot tell whether the view is already in the desired state. + const QListWidgetItem *item = eventList->item(row); + if (row == eventList->currentRow() && item && item->isSelected()) { + return; + } + } else if (row == currentRow) { + return; + } + + currentRow = row; + eventList->setCurrentRow(currentRow); + + if (currentRow >= 0) { + QListWidgetItem *item = eventList->item(currentRow); + if (!eventList->viewport()->rect().contains(eventList->visualItemRect(item))) { + eventList->scrollToItem(item, QAbstractItemView::PositionAtCenter); + } + } +} diff --git a/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h new file mode 100644 index 000000000..ee163356e --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h @@ -0,0 +1,50 @@ +#ifndef COCKATRICE_REPLAY_EVENT_SIDEBAR_H +#define COCKATRICE_REPLAY_EVENT_SIDEBAR_H + +#include +#include +#include + +class GameReplay; +class QLabel; +class QListWidget; + +/** + * @brief A scrollable list of replay events that can be used to seek the replay. + * + * The sidebar copies the event data it needs at construction time. It does not keep a reference to the replay or to + * the replay manager. + */ +class ReplayEventSidebar : public QWidget +{ + Q_OBJECT + +public: + /** + * @param parent The parent widget + * @param replay The replay to build the event list from + * @param timeline Event index to time in milliseconds, as used by ReplayManager + */ + ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList &timeline); + +public slots: + /** + * @brief Highlights the row of the event the game state has reached. + * + * @param eventIndex Index into the replay's event list, or -1 when nothing has been replayed yet + */ + void setCurrentEvent(int eventIndex); + +signals: + void eventSelected(int eventIndex); + +private: + void setCurrentRow(int row); + + QListWidget *eventList; + QLabel *emptyLabel; + QList eventIndices; ///< Replay event index of each row, ascending + int currentRow = -1; +}; + +#endif // COCKATRICE_REPLAY_EVENT_SIDEBAR_H From 7b4827dadd65c8b7a45c2cd721f232423bc8d537 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:26:51 +0100 Subject: [PATCH 236/247] [Replay] Show the history sidebar in replay tabs (#7613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Game History dock to the left of replay tabs, wire it to the replay timeline, and register it with the View menu and layout reset. Co-authored-by: Lukas Brübach --- .../interface/widgets/replay/replay_manager.h | 10 +++++ .../widgets/replay/replay_widget.cpp | 5 +++ .../interface/widgets/replay/replay_widget.h | 5 +++ .../src/interface/widgets/tabs/tab_game.cpp | 40 ++++++++++++++++++- .../src/interface/widgets/tabs/tab_game.h | 3 +- 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 34bae5f9a..e3745778e 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -66,6 +66,16 @@ public: return replayTimeline; } + /** + * @brief Index of the last event the game state has processed. + * + * @return The event index, or -1 while no event has been replayed yet + */ + [[nodiscard]] int getLastProcessedEventIndex() const + { + return currentEvent - 1; + } + void setTimeScaleFactor(qreal _timeScaleFactor); void setSkipEmptySections(bool value); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index 3f0bf35d9..e911bc0f9 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -19,6 +19,11 @@ class GameReplay; +int ReplayWidget::getLastProcessedEventIndex() const +{ + return replayManager->getLastProcessedEventIndex(); +} + ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) : QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr), aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), aReplaySkipBackwardBig(nullptr) diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 5d23b358d..cbb676288 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -38,6 +38,11 @@ public: [[nodiscard]] const QList &getReplayTimeline() const; + /** + * @brief Index of the last replay event the game state has processed, or -1 before the first event. + */ + [[nodiscard]] int getLastProcessedEventIndex() const; + public slots: void seekToEvent(int eventIndex); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 34d6f8a51..ac9edd1ba 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -27,6 +27,7 @@ #include "../interface/widgets/utility/line_edit_completer.h" #include "../main.h" #include "../menus/tearoff_menu.h" +#include "../replay/replay_event_sidebar.h" #include "../replay/replay_widget.h" #include "../utility/visibility_change_listener.h" #include "card/card_completer_proxy_model.h" @@ -98,7 +99,7 @@ class Message; } // namespace google TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) - : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) + : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr), replayDock(nullptr), historyDock(nullptr) { setObjectName("TabGame"); // THIS CTOR IS USED ON REPLAY @@ -115,6 +116,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) addDockWidget(Qt::RightDockWidgetArea, cardInfoDock); addDockWidget(Qt::RightDockWidgetArea, playerListDock); addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); + addDockWidget(Qt::LeftDockWidgetArea, historyDock); addDockWidget(Qt::BottomDockWidgetArea, replayDock); mainWidget = new QStackedWidget(this); @@ -158,6 +160,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, createPlayAreaWidget(); createDeckViewContainerWidget(); replayDock = nullptr; + historyDock = nullptr; addDockWidget(Qt::RightDockWidgetArea, cardInfoDock); addDockWidget(Qt::RightDockWidgetArea, playerListDock); @@ -353,6 +356,9 @@ void TabGame::retranslateUi() } cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString())); messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString())); + if (historyDock) { + historyDock->setWindowTitle(tr("Game History") + (historyDock->isWindow() ? tabText : QString())); + } if (replayDock) { replayDock->setWindowTitle(tr("Replay Timeline") + (replayDock->isWindow() ? tabText : QString())); } @@ -429,6 +435,9 @@ void TabGame::retranslateUi() dockToActions[messageLayoutDock].menu->setTitle(tr("Messages")); dockToActions[playerListDock].menu->setTitle(tr("Player List")); + if (historyDock) { + dockToActions[historyDock].menu->setTitle(tr("Game History")); + } if (replayDock) { dockToActions[replayDock].menu->setTitle(tr("Replay Timeline")); } @@ -1291,6 +1300,10 @@ void TabGame::createViewMenuItems() registerDockWidget(viewMenu, messageLayoutDock, {250, 200}); registerDockWidget(viewMenu, playerListDock, {250, 50}); + if (historyDock) { + registerDockWidget(viewMenu, historyDock, {280, 400}); + } + if (replayDock) { registerDockWidget(viewMenu, replayDock, {900, 100}); } @@ -1360,6 +1373,12 @@ void TabGame::actResetLayout() addDockWidget(Qt::RightDockWidgetArea, playerListDock); addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); + if (historyDock) { + historyDock->setVisible(true); + historyDock->setFloating(false); + addDockWidget(Qt::LeftDockWidgetArea, historyDock); + } + if (replayDock) { replayDock->setVisible(true); replayDock->setFloating(false); @@ -1368,6 +1387,9 @@ void TabGame::actResetLayout() cardInfoDock->resize(250, 360); messageLayoutDock->resize(250, 200); playerListDock->resize(250, 50); + if (historyDock) { + historyDock->resize(280, 400); + } replayDock->resize(900, 100); } else { cardInfoDock->resize(250, 360); @@ -1422,6 +1444,22 @@ void TabGame::createReplayDock(GameReplay *replay) [this](const auto &event, auto options) { game->getGameEventHandler()->processGameEventContainer(event, nullptr, options); }); + + auto *historySidebar = new ReplayEventSidebar(nullptr, replay, replayWidget->getReplayTimeline()); + historyDock = new QDockWidget(this); + historyDock->setObjectName("historyDock"); + historyDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetMovable); + historyDock->setWidget(historySidebar); + historyDock->setFloating(false); + + connect(historySidebar, &ReplayEventSidebar::eventSelected, replayWidget, &ReplayWidget::seekToEvent); + // The sidebar highlights events rather than times, so ask the manager which event the game state has reached. + connect(replayWidget, &ReplayWidget::timeChanged, historySidebar, + [this, historySidebar] { historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex()); }); + + // No event has been replayed yet when the sidebar is connected, so apply the initial state explicitly. + historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex()); } void TabGame::createDeckViewContainerWidget(bool bReplay) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 412529dc2..142c02999 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -50,6 +50,7 @@ class AbstractClient; class GameView; class GameScene; class ReplayWidget; +class ReplayEventSidebar; class CardInfoFrameWidget; class QLabel; class QPushButton; @@ -89,7 +90,7 @@ private: QMap deckViewContainers; QVBoxLayout *deckViewContainerLayout; QWidget *gamePlayAreaWidget, *deckViewContainerWidget; - QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock; + QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock, *historyDock; QAction *playersSeparator; QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; From b04d8b88ac52849784efd9f5cdac8c466c531e22 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:40 +0100 Subject: [PATCH 237/247] [Icons] Add SVG currentColor sentinel resolver (#7593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt's SVG renderer has no CSS cascade, so a `currentColor` fill has to be substituted before the bytes reach QSvgRenderer. - Match and replace on bytes: both the sentinel and the replacement are ASCII, so the document never goes through a UTF-16 round trip, and files that are not valid UTF-8 survive unchanged. - Document that the match is a plain case-insensitive substring, so an identifier that merely starts with the sentinel gets rewritten too. Co-authored-by: Lukas Brübach --- libcockatrice_utility/CMakeLists.txt | 2 + .../libcockatrice/utility/svg_recolor.cpp | 44 +++++++++++++ .../libcockatrice/utility/svg_recolor.h | 31 +++++++++ tests/CMakeLists.txt | 1 + tests/svg_recolor_test.cpp | 66 +++++++++++++++++++ 5 files changed, 144 insertions(+) create mode 100644 libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/svg_recolor.h create mode 100644 tests/svg_recolor_test.cpp diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 8d2cb5b92..98ad40e69 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -8,6 +8,7 @@ set(UTILITY_SOURCES libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/svg_recolor.cpp libcockatrice/utility/translation_loader.cpp libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp @@ -34,6 +35,7 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/svg_recolor.h libcockatrice/utility/translation_loader.h libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.h diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp new file mode 100644 index 000000000..041e289fe --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -0,0 +1,44 @@ +#include "svg_recolor.h" + +#include + +namespace +{ +constexpr QByteArrayView SENTINEL = "currentColor"; + +/// Case-insensitive byte search for the sentinel, starting at @p from. +/// QByteArray::indexOf() only matches case-sensitively for QByteArrayView, and +/// its char overload cannot match a word. +qsizetype findSentinel(const QByteArray &bytes, qsizetype from) +{ + // QByteArrayView::sliced() is a view, so the scan does not allocate a copy + // at every byte position (QByteArray::sliced() would). + const QByteArrayView view(bytes); + for (qsizetype i = from; i + SENTINEL.size() <= view.size(); ++i) { + if (view.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) { + return i; + } + } + return -1; +} +} // namespace + +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color) +{ + // The sentinel and the replacement are both ASCII, so stay in bytes: a + // QString round trip would decode the whole document (and mangle bytes that + // are not valid UTF-8) even on the no-op path. + qsizetype pos = findSentinel(svg, 0); + if (pos < 0) { + return svg; + } + + QByteArray resolved = svg; + while (pos >= 0) { + resolved.replace(pos, SENTINEL.size(), color); + // Skip past the inserted color: a caller's color never contains the + // sentinel, and this keeps the scan linear. + pos = findSentinel(resolved, pos + color.size()); + } + return resolved; +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h new file mode 100644 index 000000000..56d609b87 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -0,0 +1,31 @@ +/** + * @file svg_recolor.h + * @ingroup Core + */ + +#ifndef SVG_RECOLOR_H +#define SVG_RECOLOR_H + +#include + +/** + * @brief Resolves the `currentColor` sentinel in authored SVG data. + * + * Qt's SVG renderer has no CSS cascade or ambient color context, so the + * sentinel is not resolved by Qt. Callers pass the concrete color they want + * (for example a QPalette role serialized to #RRGGBB) and receive SVG bytes + * that are safe to hand to QSvgRenderer. + * + * Matching is a plain, case-insensitive substring search, not a CSS parse: an + * identifier that merely starts with the sentinel (for example + * `id="currentColorStop"`) is rewritten too. That is acceptable for the assets + * this project authors, and keeps the resolver dependency-free. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte, since the resolver never decodes the document. + * @param color Concrete SVG color (for example "#ff00ff"), as ASCII bytes. + * @return The recolored SVG source; the input is left untouched. + */ +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color); + +#endif // SVG_RECOLOR_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5b7e06c20..8b904d30a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,6 +177,7 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test( NAME diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp new file mode 100644 index 000000000..abe1e8d1a --- /dev/null +++ b/tests/svg_recolor_test.cpp @@ -0,0 +1,66 @@ +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include + +namespace +{ + +TEST(SvgRecolor, ReplacesEverySentinel) +{ + const QByteArray svg = R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_EQ(result.count("#ff8800"), 2); + EXPECT_FALSE(result.contains("currentColor")); +} + +TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2); +} + +TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact) +{ + const QByteArray svg = + R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#123456"); + + EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")")); + EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")")); + EXPECT_TRUE(result.contains("an ordinary comment")); +} + +TEST(SvgRecolor, DoesNotModifyTheInput) +{ + const QByteArray svg = R"()"; + const QByteArray original = svg; + + resolveSvgColorSentinel(svg, "#000000"); + + EXPECT_EQ(svg, original); +} + +TEST(SvgRecolor, PreservesBytesThatAreNotValidUtf8) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_FALSE(result.contains("currentColor")); + EXPECT_EQ(result.count("#ff8800"), 1); +} + +TEST(SvgRecolor, NoSentinelIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg); +} + +} // namespace From 020d9e8dcb0da71a1588c51fbd947d7c45fb9ad3 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:41 +0100 Subject: [PATCH 238/247] [Icons] Add palette-aware themed icon engine (#7594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QPixmap snapshot cannot follow palette changes, so expose a QIcon whose engine resolves the `currentColor` sentinel from the live palette on every pixmap request. Icons stay legible across light/dark switches without recreating actions or buttons. - Resolve the asset, its bytes and its intrinsic size once, and drop them on ThemeManager::themeChanged/paletteChanged. A repaint does no file I/O and no theme.cfg parse; the palette color stays part of the render cache key. - Centre the glyph on QRectF(rect).center(): QRect::center() floors and uses right = left + width - 1, which shifted every 16x16 item-view glyph up and left by a logical pixel and clipped its last row and column. - Take a raster override's intrinsic size from QImageReader instead of the 24x24 SVG default, so a non-square override reports the box it draws. - Set the device pixel ratio before inserting into the render cache, so a cache hit no longer detaches the pixmap on every paint. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/interface/theme_icon.cpp | 300 ++++++++++++++++++++++++ cockatrice/src/interface/theme_icon.h | 50 ++++ oracle/CMakeLists.txt | 1 + 4 files changed, 352 insertions(+) create mode 100644 cockatrice/src/interface/theme_icon.cpp create mode 100644 cockatrice/src/interface/theme_icon.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e99735bf7..f34961b0b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -151,6 +151,7 @@ set(cockatrice_SOURCES src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp + src/interface/theme_icon.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp src/interface/palette_editor/palette_generator.cpp diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp new file mode 100644 index 000000000..161c436ca --- /dev/null +++ b/cockatrice/src/interface/theme_icon.cpp @@ -0,0 +1,300 @@ +#include "theme_icon.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Palette color that replaces `currentColor`, following the icon mode. + */ +QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) +{ + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; + return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); +} + +/** + * @brief Reads an SVG through the `theme:` search path. + */ +QByteArray readSvg(const QString &resolvedPath) +{ + QFile file(QStringLiteral("theme:") + resolvedPath); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** + * @brief Renders SVG bytes, scaled to @p size when one is given. + * + * An empty size means the asset's intrinsic size; @p devicePixelRatio only + * applies in that case so HiDPI callers still get a crisp glyph. + */ +QPixmap renderSvg(const QByteArray &data, const QSize &size, qreal devicePixelRatio) +{ + QSvgRenderer renderer(data); + if (!renderer.isValid()) { + return {}; + } + + QSize renderSize = size; + if (renderSize.isEmpty()) { + renderSize = renderer.defaultSize(); + renderSize = + QSize(qRound(renderSize.width() * devicePixelRatio), qRound(renderSize.height() * devicePixelRatio)); + } + if (!renderSize.isValid() || renderSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(renderSize); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +bool hasSentinel(const QByteArray &svg) +{ + return !svg.isEmpty() && QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive); +} + +QString cacheKey(const QString &path, const QByteArray &variant, const QSize &size, qreal devicePixelRatio) +{ + return path + QLatin1Char('|') + QString::fromLatin1(variant) + QLatin1Char('|') + QString::number(size.width()) + + QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio); +} + +/** + * @brief QIcon engine rendering a `theme:` SVG with `currentColor` resolved on demand. + * + * Qt's SVG renderer has no ambient color, so the sentinel is substituted from + * the current palette on every pixmap request. Asset resolution, the file bytes + * and the intrinsic size are cached until the theme changes: a repaint then + * does no file I/O at all. The render cache is keyed by resolved path and pixel + * size plus a variant: the palette color for sentinel SVGs, the icon mode for + * assets that carry their own colors. A palette change therefore produces new + * keys instead of reusing art built under the previous palette, and the + * non-sentinel path (raster overrides, plain SVGs) is cached like any other. + */ +class ThemeSvgIconEngine final : public QObject, public QIconEngine +{ +public: + ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) + { + if (themeManager) { + // A theme switch changes the resolved asset (scheme-qualified + // variants, raster overrides) and a palette change invalidates + // every cached colour, so both drop the caches instead of + // re-reading theme.cfg and the assets on every repaint. + connect(themeManager, &ThemeManager::themeChanged, this, &ThemeSvgIconEngine::invalidate); + connect(themeManager, &ThemeManager::paletteChanged, this, &ThemeSvgIconEngine::invalidate); + } + } + + QIconEngine *clone() const override + { + return new ThemeSvgIconEngine(prefix, role); + } + + void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override + { + const qreal devicePixelRatio = painter->device() ? painter->device()->devicePixelRatioF() : 1.0; + const QSize logicalSize = fitToSize(rect.size()); + if (logicalSize.isEmpty()) { + return; + } + const QSize pixelSize(qRound(logicalSize.width() * devicePixelRatio), + qRound(logicalSize.height() * devicePixelRatio)); + const QPixmap pixmap = render(pixelSize, devicePixelRatio, mode); + if (!pixmap.isNull()) { + // QRect::center() would floor and drop the last row/column of a + // 16x16 glyph; the center has to be fractional so glyphs stay + // unshifted and unsclipped in item views. + const QSizeF contentSize = pixmap.deviceIndependentSize(); + const QRectF targetRect( + QRectF(rect).center() - QPointF(contentSize.width() / 2.0, contentSize.height() / 2.0), contentSize); + painter->drawPixmap(targetRect, pixmap, QRectF(pixmap.rect())); + } + } + + QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State) override + { + return render(fitToSize(size), 1.0, mode); + } + + QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State, qreal scale) override + { + const QSize logicalSize = fitToSize(size); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const QSize pixelSize(qRound(logicalSize.width() * scale), qRound(logicalSize.height() * scale)); +#else + // Qt < 6.8 passes the device-dependent size to the engine. + const QSize pixelSize = logicalSize; +#endif + return render(pixelSize, scale, mode); + } + + QSize actualSize(const QSize &size, QIcon::Mode, QIcon::State) override + { + return fitToSize(size); + } + +private: + void invalidate() + { + hasResolvedAsset = false; + hasIntrinsicSize = false; + cache.clear(); + } + + void ensureResolved() const + { + if (hasResolvedAsset && (!themeManager || themeManager->getCurrentThemePath() == resolvedThemePath)) { + return; + } + resolvedThemePath = themeManager ? themeManager->getCurrentThemePath() : QString(); + resolvedPath = themeManager ? themeManager->assetPath(prefix) : QString(); + resolvedAssetIsSvg = resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive); + resolvedSvg = resolvedAssetIsSvg ? readSvg(resolvedPath) : QByteArray(); + resolvedHasSentinel = hasSentinel(resolvedSvg); + hasResolvedAsset = true; + hasIntrinsicSize = false; + } + + QSize intrinsicSize() const + { + ensureResolved(); + if (!hasIntrinsicSize) { + if (resolvedAssetIsSvg) { + QSvgRenderer renderer(resolvedSvg); + intrinsicSizeCache = renderer.isValid() ? renderer.defaultSize() : QSize(); + } else { + // A theme may override a glyph with a raster; take its real + // aspect ratio instead of assuming the 24x24 SVG default, so + // actualSize() and the rendered pixmap agree. + QImageReader reader(QStringLiteral("theme:") + resolvedPath); + intrinsicSizeCache = reader.size(); + } + if (intrinsicSizeCache.isEmpty()) { + intrinsicSizeCache = QSize(24, 24); + } + hasIntrinsicSize = true; + } + return intrinsicSizeCache; + } + + QSize fitToSize(const QSize &size) const + { + if (size.isEmpty()) { + return size; + } + return intrinsicSize().scaled(size, Qt::KeepAspectRatio); + } + + QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const + { + QPixmap pixmap(QStringLiteral("theme:") + resolvedPath); + if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { + pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + if (mode == QIcon::Disabled && !pixmap.isNull()) { + pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr); + } + return pixmap; + } + + QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const + { + ensureResolved(); + + // Same cache for both paths: sentinel art varies by palette color, art + // with its own colors varies by mode (Disabled is generated). A color + // variant is never a bare digit, so the two key spaces cannot collide. + const QByteArray variant = + resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast(mode)); + const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); + const auto cached = cache.constFind(key); + if (cached != cache.constEnd()) { + return cached.value(); + } + + QPixmap pixmap; + if (resolvedHasSentinel) { + pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); + } else { + // Authored art carries its own colors: use Qt's normal loader so + // raster overrides and non-SVG assets keep working. + pixmap = fromAsset(pixelSize, mode); + } + if (!pixmap.isNull()) { + // Set the ratio before caching: assigning it to a cached pixmap + // afterwards detaches (deep-copies) it on every paint. + pixmap.setDevicePixelRatio(devicePixelRatio); + } + if (cache.size() >= 64) { + cache.clear(); + } + cache.insert(key, pixmap); + return pixmap; + } + + QString prefix; + QPalette::ColorRole role; + mutable QHash cache; + mutable QString resolvedPath; + mutable QString resolvedThemePath; + mutable QByteArray resolvedSvg; + mutable QSize intrinsicSizeCache; + mutable bool resolvedAssetIsSvg = false; + mutable bool resolvedHasSentinel = false; + mutable bool hasResolvedAsset = false; + mutable bool hasIntrinsicSize = false; +}; +} // namespace + +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) +{ + if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { + return {}; + } + + const QByteArray svg = readSvg(resolvedPath); + if (!hasSentinel(svg)) { + return {}; + } + + const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); + return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0); +} diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h new file mode 100644 index 000000000..e26015ea1 --- /dev/null +++ b/cockatrice/src/interface/theme_icon.h @@ -0,0 +1,50 @@ +/** + * @file theme_icon.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_H +#define THEME_ICON_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Builds a palette-aware icon from a `theme:` asset. + * + * SVG glyphs may use the `currentColor` sentinel. A plain QPixmap snapshot + * cannot follow palette changes, so this returns a QIcon whose engine resolves + * the sentinel from the current palette every time it is asked for a pixmap. + * Icons therefore stay legible when the user switches between light and dark + * themes without recreating any actions or buttons. + * + * @param prefix Asset prefix relative to the `theme:` search path, with no file + * extension (for example `icons/search`), matching themePixmap(). + * @param role Palette role whose color replaces the sentinel. Selected glyphs + * use HighlightedText unless a role other than WindowText is given. + * @return An icon that follows the application palette. + */ +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); + +/** + * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. + * + * Non-SVG assets and SVGs without the sentinel return a null pixmap so callers + * can fall back to their usual loading path (including legacy inversion). + * + * @param resolvedPath Asset path relative to the `theme:` search path, as + * returned by ThemeManager::assetPath (includes extension). + * @param role Palette role whose color replaces the sentinel. + * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. + * @return The recolored pixmap, or a null pixmap when the asset is not a + * sentinel-carrying SVG. + */ +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, + QPalette::ColorRole role = QPalette::WindowText, + const QSize &size = {}); + +#endif // THEME_ICON_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index f68f51f23..4ca2acd07 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -31,6 +31,7 @@ set(oracle_SOURCES ../cockatrice/src/client/network/update/client/release_channel.cpp ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp + ../cockatrice/src/interface/theme_icon.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} ) From 627c7a83aafee46f51ca7f5e58affb55af561424 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:41 +0100 Subject: [PATCH 239/247] [Icons] Migrate UI glyphs to palette-token SVGs (#7595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Icons] Migrate UI glyphs to palette-token SVGs Runtime inversion is not a theming system: it rotates hue, breaks duotone art and mishandles alpha, and a QPixmap snapshot cannot follow a palette change. Redraw the top-level UI glyph set on a 24x24 grid with `currentColor` as the paint token, drop Inkscape/Sodipodi cruft, and delete the icons whose metaphor was duplicated (cogwheel/gear -> settings, scale_balanced -> scales, update.png -> reload). Load the glyphs through `loadThemedIcon` so the engine resolves the token from the palette on every request, and let `themePixmap` use the same resolution for one-shot pixmaps. `loadColorAdjustedPixmap` is gone; every caller now keeps a QIcon, which `SettingsButtonWidget::setButtonIcon` accepts instead of a QPixmap. Content art (mana, counters, countries) keeps its authored colors. Add a render test covering aspect ratio, palette flips, icon modes and high-DPI pixmaps. * [Tests] Keep the icon render test QApplication alive until exit Creating it as a function-local static tears it down before Qt's library statics at exit, and libQt6Network then runs QObject destructors against freed memory — a SEGV the sanitizer leg reports after the tests have already passed. Leak the application instead, the usual pattern for a test binary. * [Icons] Render lock and dropdown glyphs through the icon engine Both generators rendered the sentinel SVG to a fixed height x height pixmap at DPR 1. The pre-migration path rendered larger and let QIcon scale it, so on a 2x display the games-list lock (13px) and the banner dropdown arrow (24px) came back crisp; the new path hands the widget a DPR-1 bitmap that gets upscaled and blurred. - The games list asks the themed engine for `icons/lock` directly, so the item view paints it at the device pixel ratio and the lock also follows HighlightedText on a selected row. - The dropdown goes through `loadThemedIcon(...).pixmap(size, dpr)`, which keeps the glyph's aspect ratio and renders at the requested ratio. - LockPixmapGenerator has no consumer left, so it and its cache are gone. --------- Co-authored-by: Lukas Brübach --- cockatrice/cockatrice.qrc | 18 +- .../resources/icons/arrow_bottom_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_down.svg | 4 + .../resources/icons/arrow_down_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_history.svg | 13 +- cockatrice/resources/icons/arrow_left.svg | 4 + .../resources/icons/arrow_left_green.svg | 791 ----------------- cockatrice/resources/icons/arrow_redo.svg | 44 +- cockatrice/resources/icons/arrow_right.svg | 4 + .../resources/icons/arrow_right_blue.svg | 789 ----------------- .../resources/icons/arrow_right_green.svg | 789 ----------------- .../resources/icons/arrow_right_sideboard.svg | 5 + .../resources/icons/arrow_to_bottom.svg | 5 + cockatrice/resources/icons/arrow_to_top.svg | 5 + .../resources/icons/arrow_top_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_undo.svg | 44 +- cockatrice/resources/icons/arrow_up.svg | 4 + cockatrice/resources/icons/arrow_up_green.svg | 792 ----------------- .../resources/icons/circle_half_stroke.svg | 5 +- cockatrice/resources/icons/clearsearch.svg | 278 +----- cockatrice/resources/icons/cogwheel.svg | 29 - cockatrice/resources/icons/conceded.svg | 105 +-- cockatrice/resources/icons/decrement.svg | 206 +---- cockatrice/resources/icons/delete.svg | 360 +------- cockatrice/resources/icons/dragon.svg | 7 +- .../resources/icons/dropdown_collapsed.svg | 11 +- .../resources/icons/dropdown_expanded.svg | 11 +- cockatrice/resources/icons/filter.svg | 15 +- cockatrice/resources/icons/floppy_disk.svg | 6 +- .../resources/icons/forgot_password.svg | 10 +- cockatrice/resources/icons/gear.svg | 1 - cockatrice/resources/icons/increment.svg | 201 +---- cockatrice/resources/icons/info.svg | 46 +- cockatrice/resources/icons/lock.svg | 752 +---------------- .../resources/icons/not_ready_start.svg | 370 +------- cockatrice/resources/icons/pen_to_square.svg | 5 +- cockatrice/resources/icons/pencil.svg | 125 +-- cockatrice/resources/icons/pin.svg | 26 +- cockatrice/resources/icons/player.svg | 148 +--- cockatrice/resources/icons/ready_start.svg | 82 +- cockatrice/resources/icons/reload.svg | 11 +- cockatrice/resources/icons/remove_row.svg | 358 +------- cockatrice/resources/icons/rename.svg | 15 +- cockatrice/resources/icons/scale_balanced.svg | 1 - cockatrice/resources/icons/scales.svg | 11 +- cockatrice/resources/icons/scroll.svg | 7 +- cockatrice/resources/icons/search.svg | 202 +---- cockatrice/resources/icons/settings.svg | 282 +------ cockatrice/resources/icons/share.svg | 30 +- .../resources/icons/sort_arrow_down.svg | 8 +- cockatrice/resources/icons/spectator.svg | 108 +-- cockatrice/resources/icons/swap.svg | 18 +- cockatrice/resources/icons/sync.svg | 10 +- cockatrice/resources/icons/tab_changed.svg | 118 +-- cockatrice/resources/icons/update.png | Bin 7262 -> 0 bytes cockatrice/resources/icons/view.svg | 33 +- cockatrice/src/filters/filter_builder.cpp | 4 +- .../player/player_list_widget.cpp | 15 +- .../game_graphics/zones/view_zone_widget.cpp | 5 +- .../src/interface/pixel_map_generator.cpp | 54 +- .../src/interface/pixel_map_generator.h | 23 +- .../abstract_analytics_panel_widget.cpp | 4 +- .../deck_editor_database_display_widget.cpp | 10 +- .../deck_editor_deck_dock_widget.cpp | 10 +- .../deck_editor_filter_dock_widget.cpp | 6 +- .../deck_list_history_manager_widget.cpp | 8 +- .../interface/widgets/dialogs/dlg_connect.cpp | 8 +- .../widgets/dialogs/dlg_edit_tokens.cpp | 6 +- .../widgets/dialogs/dlg_manage_sets.cpp | 12 +- .../widgets/dialogs/dlg_register.cpp | 6 +- .../widgets/dialogs/settings_view.cpp | 4 +- .../quick_settings/settings_button_widget.cpp | 10 +- .../quick_settings/settings_button_widget.h | 4 +- .../widgets/server/game_selector.cpp | 6 +- .../interface/widgets/server/games_model.cpp | 8 +- .../interface/widgets/server/games_model.h | 2 + .../remote/remote_replay_list_tree_widget.cpp | 4 +- .../deck_editor_settings_page.cpp | 10 +- .../settings_page/messages_settings_page.cpp | 8 +- .../settings_page/shortcut_settings_page.cpp | 6 +- .../tabs/api/archidekt/tab_archidekt.cpp | 4 +- .../commander_bracket_widget.cpp | 4 +- .../widgets/tabs/tab_deck_storage.cpp | 20 +- .../widgets/tabs/tab_public_decks.cpp | 7 +- .../interface/widgets/tabs/tab_replays.cpp | 20 +- .../src/interface/widgets/tabs/tab_room.cpp | 4 +- .../interface/widgets/tabs/tab_supervisor.cpp | 3 +- .../widgets/utility/sequence_edit.cpp | 6 +- ...database_display_filter_toolbar_widget.cpp | 20 +- .../visual_database_display_widget.cpp | 10 +- .../visual_deck_display_options_widget.cpp | 8 +- .../visual_deck_editor_widget.cpp | 4 +- .../visual_deck_storage_search_widget.cpp | 6 +- .../visual_deck_storage_widget.cpp | 6 +- cockatrice/src/interface/window_main.cpp | 5 +- tests/theme_migration/CMakeLists.txt | 4 + .../theme_icon_render_test.cpp | 189 +++++ 97 files changed, 543 insertions(+), 9711 deletions(-) delete mode 100644 cockatrice/resources/icons/arrow_bottom_green.svg create mode 100644 cockatrice/resources/icons/arrow_down.svg delete mode 100644 cockatrice/resources/icons/arrow_down_green.svg create mode 100644 cockatrice/resources/icons/arrow_left.svg delete mode 100644 cockatrice/resources/icons/arrow_left_green.svg create mode 100644 cockatrice/resources/icons/arrow_right.svg delete mode 100644 cockatrice/resources/icons/arrow_right_blue.svg delete mode 100644 cockatrice/resources/icons/arrow_right_green.svg create mode 100644 cockatrice/resources/icons/arrow_right_sideboard.svg create mode 100644 cockatrice/resources/icons/arrow_to_bottom.svg create mode 100644 cockatrice/resources/icons/arrow_to_top.svg delete mode 100644 cockatrice/resources/icons/arrow_top_green.svg create mode 100644 cockatrice/resources/icons/arrow_up.svg delete mode 100644 cockatrice/resources/icons/arrow_up_green.svg delete mode 100644 cockatrice/resources/icons/cogwheel.svg delete mode 100644 cockatrice/resources/icons/gear.svg delete mode 100644 cockatrice/resources/icons/scale_balanced.svg delete mode 100644 cockatrice/resources/icons/update.png create mode 100644 tests/theme_migration/theme_icon_render_test.cpp diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 4ec3d2385..229cdd125 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -6,19 +6,18 @@ resources/hand.svg resources/hr.jpg - resources/icons/arrow_bottom_green.svg - resources/icons/arrow_down_green.svg + resources/icons/arrow_down.svg resources/icons/arrow_history.svg - resources/icons/arrow_left_green.svg + resources/icons/arrow_left.svg resources/icons/arrow_redo.svg - resources/icons/arrow_right_blue.svg - resources/icons/arrow_right_green.svg - resources/icons/arrow_top_green.svg - resources/icons/arrow_up_green.svg + resources/icons/arrow_right.svg + resources/icons/arrow_right_sideboard.svg + resources/icons/arrow_to_bottom.svg + resources/icons/arrow_to_top.svg + resources/icons/arrow_up.svg resources/icons/arrow_undo.svg resources/icons/circle_half_stroke.svg resources/icons/clearsearch.svg - resources/icons/cogwheel.svg resources/icons/conceded.svg resources/icons/decrement.svg resources/icons/delete.svg @@ -28,7 +27,6 @@ resources/icons/filter.svg resources/icons/floppy_disk.svg resources/icons/forgot_password.svg - resources/icons/gear.svg resources/icons/increment.svg resources/icons/info.svg resources/icons/lock.svg @@ -41,7 +39,6 @@ resources/icons/reload.svg resources/icons/remove_row.svg resources/icons/rename.svg - resources/icons/scale_balanced.svg resources/icons/scales.svg resources/icons/scroll.svg resources/icons/search.svg @@ -52,7 +49,6 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg - resources/icons/update.png resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/icons/arrow_bottom_green.svg b/cockatrice/resources/icons/arrow_bottom_green.svg deleted file mode 100644 index 3a45053ad..000000000 --- a/cockatrice/resources/icons/arrow_bottom_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_down.svg b/cockatrice/resources/icons/arrow_down.svg new file mode 100644 index 000000000..0ff237f52 --- /dev/null +++ b/cockatrice/resources/icons/arrow_down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_down_green.svg b/cockatrice/resources/icons/arrow_down_green.svg deleted file mode 100644 index 574c12060..000000000 --- a/cockatrice/resources/icons/arrow_down_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_history.svg b/cockatrice/resources/icons/arrow_history.svg index d130758e6..29374d23a 100644 --- a/cockatrice/resources/icons/arrow_history.svg +++ b/cockatrice/resources/icons/arrow_history.svg @@ -1,8 +1,5 @@ - - - - history - - - - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/arrow_left.svg b/cockatrice/resources/icons/arrow_left.svg new file mode 100644 index 000000000..7a88deddb --- /dev/null +++ b/cockatrice/resources/icons/arrow_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_left_green.svg b/cockatrice/resources/icons/arrow_left_green.svg deleted file mode 100644 index 1b9eb0684..000000000 --- a/cockatrice/resources/icons/arrow_left_green.svg +++ /dev/null @@ -1,791 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_redo.svg b/cockatrice/resources/icons/arrow_redo.svg index dd89e3314..141260269 100644 --- a/cockatrice/resources/icons/arrow_redo.svg +++ b/cockatrice/resources/icons/arrow_redo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_right.svg b/cockatrice/resources/icons/arrow_right.svg new file mode 100644 index 000000000..f0ac5d5bf --- /dev/null +++ b/cockatrice/resources/icons/arrow_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_right_blue.svg b/cockatrice/resources/icons/arrow_right_blue.svg deleted file mode 100644 index 334cd68fd..000000000 --- a/cockatrice/resources/icons/arrow_right_blue.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_green.svg b/cockatrice/resources/icons/arrow_right_green.svg deleted file mode 100644 index db491985d..000000000 --- a/cockatrice/resources/icons/arrow_right_green.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_sideboard.svg b/cockatrice/resources/icons/arrow_right_sideboard.svg new file mode 100644 index 000000000..79a8cd5d3 --- /dev/null +++ b/cockatrice/resources/icons/arrow_right_sideboard.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_bottom.svg b/cockatrice/resources/icons/arrow_to_bottom.svg new file mode 100644 index 000000000..291a527d2 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_bottom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_top.svg b/cockatrice/resources/icons/arrow_to_top.svg new file mode 100644 index 000000000..aac38cc33 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_top.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_top_green.svg b/cockatrice/resources/icons/arrow_top_green.svg deleted file mode 100644 index c515c6a1d..000000000 --- a/cockatrice/resources/icons/arrow_top_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_undo.svg b/cockatrice/resources/icons/arrow_undo.svg index 2e182ffb2..c748b37b8 100644 --- a/cockatrice/resources/icons/arrow_undo.svg +++ b/cockatrice/resources/icons/arrow_undo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_up.svg b/cockatrice/resources/icons/arrow_up.svg new file mode 100644 index 000000000..ac32b5c92 --- /dev/null +++ b/cockatrice/resources/icons/arrow_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_up_green.svg b/cockatrice/resources/icons/arrow_up_green.svg deleted file mode 100644 index 1d0f61112..000000000 --- a/cockatrice/resources/icons/arrow_up_green.svg +++ /dev/null @@ -1,792 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/circle_half_stroke.svg b/cockatrice/resources/icons/circle_half_stroke.svg index 42e4dbca2..c16f84858 100644 --- a/cockatrice/resources/icons/circle_half_stroke.svg +++ b/cockatrice/resources/icons/circle_half_stroke.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/clearsearch.svg b/cockatrice/resources/icons/clearsearch.svg index be50d68d6..49d883c68 100644 --- a/cockatrice/resources/icons/clearsearch.svg +++ b/cockatrice/resources/icons/clearsearch.svg @@ -1,276 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/cogwheel.svg b/cockatrice/resources/icons/cogwheel.svg deleted file mode 100644 index 8c7aad617..000000000 --- a/cockatrice/resources/icons/cogwheel.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cockatrice/resources/icons/conceded.svg b/cockatrice/resources/icons/conceded.svg index a487f18bb..b9faf047c 100644 --- a/cockatrice/resources/icons/conceded.svg +++ b/cockatrice/resources/icons/conceded.svg @@ -1,103 +1,4 @@ - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/decrement.svg b/cockatrice/resources/icons/decrement.svg index 0ceddf69f..033245f85 100644 --- a/cockatrice/resources/icons/decrement.svg +++ b/cockatrice/resources/icons/decrement.svg @@ -1,205 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + diff --git a/cockatrice/resources/icons/delete.svg b/cockatrice/resources/icons/delete.svg index 008d8d429..d79312f1e 100644 --- a/cockatrice/resources/icons/delete.svg +++ b/cockatrice/resources/icons/delete.svg @@ -1,357 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/dragon.svg b/cockatrice/resources/icons/dragon.svg index f45af39ef..cc48d1f5c 100644 --- a/cockatrice/resources/icons/dragon.svg +++ b/cockatrice/resources/icons/dragon.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/dropdown_collapsed.svg b/cockatrice/resources/icons/dropdown_collapsed.svg index 52e833e65..429c24974 100644 --- a/cockatrice/resources/icons/dropdown_collapsed.svg +++ b/cockatrice/resources/icons/dropdown_collapsed.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/dropdown_expanded.svg b/cockatrice/resources/icons/dropdown_expanded.svg index fa3ee205c..0b24aeb48 100644 --- a/cockatrice/resources/icons/dropdown_expanded.svg +++ b/cockatrice/resources/icons/dropdown_expanded.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/filter.svg b/cockatrice/resources/icons/filter.svg index d53d9ba29..d9cdf879c 100644 --- a/cockatrice/resources/icons/filter.svg +++ b/cockatrice/resources/icons/filter.svg @@ -1,12 +1,3 @@ - - - - - - - - \ No newline at end of file + + + diff --git a/cockatrice/resources/icons/floppy_disk.svg b/cockatrice/resources/icons/floppy_disk.svg index 3c5b5054d..10b9e8172 100644 --- a/cockatrice/resources/icons/floppy_disk.svg +++ b/cockatrice/resources/icons/floppy_disk.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/forgot_password.svg b/cockatrice/resources/icons/forgot_password.svg index f938ca3f4..3bfb3bdb1 100644 --- a/cockatrice/resources/icons/forgot_password.svg +++ b/cockatrice/resources/icons/forgot_password.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/gear.svg b/cockatrice/resources/icons/gear.svg deleted file mode 100644 index fdee3a297..000000000 --- a/cockatrice/resources/icons/gear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/increment.svg b/cockatrice/resources/icons/increment.svg index 6d5f7a85a..52dbdb2aa 100644 --- a/cockatrice/resources/icons/increment.svg +++ b/cockatrice/resources/icons/increment.svg @@ -1,199 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/info.svg b/cockatrice/resources/icons/info.svg index 00b23cc2c..1305f6b3d 100644 --- a/cockatrice/resources/icons/info.svg +++ b/cockatrice/resources/icons/info.svg @@ -1,43 +1,5 @@ - - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/lock.svg b/cockatrice/resources/icons/lock.svg index b7ebf1a53..f150ece49 100644 --- a/cockatrice/resources/icons/lock.svg +++ b/cockatrice/resources/icons/lock.svg @@ -1,750 +1,4 @@ - - - - - - - - - padlock - - - - icon - - lock - security - secure - padlock - signs_and_symbols - - - - - AJ Ashton - - - - - AJ Ashton - - - - - AJ Ashton - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/not_ready_start.svg b/cockatrice/resources/icons/not_ready_start.svg index 73afda9db..c20a5cfe6 100644 --- a/cockatrice/resources/icons/not_ready_start.svg +++ b/cockatrice/resources/icons/not_ready_start.svg @@ -1,368 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pen_to_square.svg b/cockatrice/resources/icons/pen_to_square.svg index 60ff1d9c4..7d3e161ac 100644 --- a/cockatrice/resources/icons/pen_to_square.svg +++ b/cockatrice/resources/icons/pen_to_square.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/pencil.svg b/cockatrice/resources/icons/pencil.svg index c8b81f698..d274e98db 100644 --- a/cockatrice/resources/icons/pencil.svg +++ b/cockatrice/resources/icons/pencil.svg @@ -1,123 +1,4 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pin.svg b/cockatrice/resources/icons/pin.svg index 71d4c9c9c..9c173286b 100644 --- a/cockatrice/resources/icons/pin.svg +++ b/cockatrice/resources/icons/pin.svg @@ -1,24 +1,4 @@ - - - - - + + + - diff --git a/cockatrice/resources/icons/player.svg b/cockatrice/resources/icons/player.svg index e83b24654..9fbe39a11 100644 --- a/cockatrice/resources/icons/player.svg +++ b/cockatrice/resources/icons/player.svg @@ -1,142 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + + + + diff --git a/cockatrice/resources/icons/ready_start.svg b/cockatrice/resources/icons/ready_start.svg index 238d2aa46..4c4ea5d2d 100644 --- a/cockatrice/resources/icons/ready_start.svg +++ b/cockatrice/resources/icons/ready_start.svg @@ -1,81 +1,3 @@ - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + diff --git a/cockatrice/resources/icons/reload.svg b/cockatrice/resources/icons/reload.svg index 102995876..e09b1b7ef 100644 --- a/cockatrice/resources/icons/reload.svg +++ b/cockatrice/resources/icons/reload.svg @@ -1,7 +1,4 @@ - - - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/remove_row.svg b/cockatrice/resources/icons/remove_row.svg index 6f133cdb6..5d00ed57c 100644 --- a/cockatrice/resources/icons/remove_row.svg +++ b/cockatrice/resources/icons/remove_row.svg @@ -1,356 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/rename.svg b/cockatrice/resources/icons/rename.svg index c68a00246..bab850808 100644 --- a/cockatrice/resources/icons/rename.svg +++ b/cockatrice/resources/icons/rename.svg @@ -1,10 +1,7 @@ - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scale_balanced.svg b/cockatrice/resources/icons/scale_balanced.svg deleted file mode 100644 index fae468d38..000000000 --- a/cockatrice/resources/icons/scale_balanced.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/scales.svg b/cockatrice/resources/icons/scales.svg index a410921dd..4b2e16fcc 100644 --- a/cockatrice/resources/icons/scales.svg +++ b/cockatrice/resources/icons/scales.svg @@ -1,6 +1,7 @@ - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scroll.svg b/cockatrice/resources/icons/scroll.svg index f4828e679..edcf29d11 100644 --- a/cockatrice/resources/icons/scroll.svg +++ b/cockatrice/resources/icons/scroll.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/search.svg b/cockatrice/resources/icons/search.svg index 05439a12e..be4a87f00 100644 --- a/cockatrice/resources/icons/search.svg +++ b/cockatrice/resources/icons/search.svg @@ -1,198 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/settings.svg b/cockatrice/resources/icons/settings.svg index 77a2ccfa9..3942a0916 100644 --- a/cockatrice/resources/icons/settings.svg +++ b/cockatrice/resources/icons/settings.svg @@ -1,280 +1,4 @@ - - - - - - - - Ruota dentata grigia - - - - unsorted - - - - - Open Clip Art Library - - - - - Architetto Francesco Rollandin - - - - - Architetto Francesco Rollandin - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/share.svg b/cockatrice/resources/icons/share.svg index a84f55847..9d6ff21a0 100644 --- a/cockatrice/resources/icons/share.svg +++ b/cockatrice/resources/icons/share.svg @@ -1,25 +1,7 @@ - - - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/sort_arrow_down.svg b/cockatrice/resources/icons/sort_arrow_down.svg index adf10f9f0..e10b401f2 100644 --- a/cockatrice/resources/icons/sort_arrow_down.svg +++ b/cockatrice/resources/icons/sort_arrow_down.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/cockatrice/resources/icons/spectator.svg b/cockatrice/resources/icons/spectator.svg index c580c448d..19242f058 100644 --- a/cockatrice/resources/icons/spectator.svg +++ b/cockatrice/resources/icons/spectator.svg @@ -1,106 +1,4 @@ - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/swap.svg b/cockatrice/resources/icons/swap.svg index a18ca2f06..4eddb8c08 100644 --- a/cockatrice/resources/icons/swap.svg +++ b/cockatrice/resources/icons/swap.svg @@ -1,12 +1,6 @@ - - - - - - - - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/sync.svg b/cockatrice/resources/icons/sync.svg index e287b1993..fa00b3024 100644 --- a/cockatrice/resources/icons/sync.svg +++ b/cockatrice/resources/icons/sync.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/tab_changed.svg b/cockatrice/resources/icons/tab_changed.svg index ae09ef8fd..1d9b8d09c 100644 --- a/cockatrice/resources/icons/tab_changed.svg +++ b/cockatrice/resources/icons/tab_changed.svg @@ -1,116 +1,4 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/update.png b/cockatrice/resources/icons/update.png deleted file mode 100644 index 825c0aee757322230b41bc9ef0be3ed08260a5d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7262 zcmV-k9HHZhP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00004XF*Lt006O$eEU(800001 zb5ch_0Itp)=>Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01m_e01m_fl`9S#000}c zNklkj_AbmvK$L*N79VLB$^l{SrNbeV4Yql9C0D@>IeUZ40 z6}OzmcjOHG1|yFLL^5D+BfVQBi%EbG1HORtWN{lCB5?hAEIl?zdOPVYVjnRH5O|9t zeF^DHNspgFaHm2rv<~&8H;~?A62O1ZQ$)~1F|tG98niwW$xwGSk|9_GKtZ;c^fc0w zrg>yf_h+J;s3g6Sbk}?*0Qzw!>8YYhpN%6sR0KlsYe+8>Ba3-OfJD->L_x>RB5*?i zzTWrdMb}VHdh69r01VSsh(11K;oA1?+mr6T`|jmgSy{;o7A#1Njg7V2Y&J(sOpGHe zEG!~AIvTg(%0DnPG}P%PZD3%av%kN;x390Sr@p?vskODW=lJpC4F?V!Xd4_HoCqOd z&@Egbz0>FkeRSV9a)0ySA9H6r0npT6D*|r`=E!~U!3USzb=O_XSFc{3m6n#aoB*B{ z5fNbz#g82yA0KINZ?7YysIIB0X?XL^H!mDHa-=H+772;)nGx6U=)MPeaC3RT*)cOD z&?5lG?%ATao#uOxZde>UKbx9zjP)|Ah8myRaI3N3kwS`KL7ml z=Ltc^g5++{HB^)SIe4;>)bAX7B=k--@0Kl#Z|R_@rb{``sEyLTTWuA(jot^vcx zGo*LO2=Ip-0e-;>2wN`i_tQuMC5v&?Un7QeQ&z^gxw$JJd+f1qq@<)=cQqtSh-N4z zL^wcPL}`Go0WH8e(u=*;00|&60E8XU9sK^G_j9L{0GPe|66jCHX#PktQf_(Rfd{_f za5&Q8Ds0`|-Oc; zN#woy>Z{j3{P4ryBno~x%!Ag|)z!FX&z|>w_`@H55r8#7vH9skd;hN73%cijaVuJa zy^s7kq$I$(^^Ia^=QsFIKKW$Ud+)vX1PSAEh>u?g^Q1*bN82}S*zlD{9(e=_-+JcE zneHjL1{`qd*FCWM9u@fBFoJ_kYwlZVz4Srp(NGfL8iU~bj$tVG(MKQU5I_GNV(!y8 zjkk z+qT4G$Btd@?Cdm@P+Cg};1hr1=lMt=6NRv8^*z^Giw~3^4TS~JWb31kKAQUOyYK$k z>2%&aU(G!WeC4qvOO|91L3|5|7|M%^ihA|V?YjF{ZU+hY>5I~#XRuckf<8WYx5OO; zmjp@zsJ14}S1;TwGj|3A-A6sTJ_Hh=_>jEnBwSx_G$!!jKvv{xi}Mi@c^&X2kxDJyL;QS!6bmuD8K}rGU92+3?|^@;7Z<}u{d2If*O3@ zs}FbH|Hig}a|Bl1FF4Oz%Rn<+>1%|>-a2RyXg4j%eL z&aY{iK3}p&A^N05LMwo~wJo)iVInUh-LCsz-x~JkfBx{9KnTE@;7>L;KLMrfC2+Cz z3n=d>6~I@IQpJ`5zRY(kf_!c8nVcc))il*k5=2TA9(LaQ`c~LKMG{#3 z%wAo!ZFp=5j@2E7(+$U=xxaZ5Fr^7rf-k|0mmw;34J!O6!RM8Pkh&3xC2#}OG}p*e z#o)^x?!5O`Ux)qwa`2fz2{7-#9~~csk1GEHg#`Q|*Pv>YD#2BYa>)T+GCHLOfab>) z$sReCBt#HjlDQ;@Gr$_?pd^P=UGNew_AA^r7{tDeAUDQ#^OGO?HD5(!u z=JKWPf|2RdvnZ3+LbL=+oWgE(Q;mA!Ncc_s&h5Ya`ov$JJZM-PTIQqa?;P%ce?R>* z*n8=>67bbPfntlWLry{h>^yA> z87+aE-?{BS?s(Q83ozx8-_z3rm6ervvBa!5`F}2Z7xonW7Yq@>r!`7>L!(p*B+c(@ z&hW9_iP8Wttb#MeDRf@(;>qvI?cDYYeG*s=CoD7?T7dfcdVvJV$;l8C6Eo|;|J{YR z;7sF5-BGFpH#$5995L}=kB*1vh)9UEMuWvS9U>KplRzj(t}JOJ6L)OUr}~$2+_^MU z_$}2}YCM8f;i^dBuygCHx50=1mjBFDM4;c+ps%k_=m`l4uz2xeh>D7u72v;7^h+VG z*Bhl?pyH$KkYrm7@dWe;YdEL^G+qg_pWs8_;Cl~dE_IUMNQ7ZywJOmhJm9#UTVJ_t z;=}Ld=T4gl?CR=*l9Cdrt*ynYYiAVvcP{=9Tx>fp!HuoX7sMvQ+NEn@_0l!4$hJrV zzS15m;N!K^#;SM7j)OM%DjBGD6IrR5LYG-JiLT%|*!A_7Z+q6L2n?Ru?(Bq1moCBO z%a@_Ix7VLsc~IJc^7r9X;|bkNUSwDVtX#4RvXisH7G& z`aRCnEaDQJD#7V{Imhq3^W{y?8qokjC4}4U7Qjcb7#SG}iHV7jl$0dwhg$6)sW~K1 zx=>QGsg9+jr-H>|(FU1k7#9Z1l9m&*zd(P4^J){UCHh$4)6%?J zieFm~$VrGApsJyoPI$op@OcNHD##7G2y{3cg76z3ANLESp`jrd92^v)hv?{Nu-olI z$IlDII-uWw`-?rEP>&({tVG3UC$A*t-mU>Mbxist`dHuxBKl~y1;MWt!B-L9b5f|_ zYlVS^M?Q{TtJMnq{rv){Q?(4(+uPgmLJFbB#l;Dqjby+c5fLFg#^=Jr!v&MAJn<+v zk2f5J%I*rf8N?q<-nS}wHN-~7DZpoV;b4e9@I&`^p(UY6emAy`-7E&0pS3QT%EO@lx;=0?{WN z1@QB8^%6lr^ZVYBik4ttV8Cy;5U}tYE`p(n0cx@b|4BV=xURI`Y)e$E>N)W(rtY(TnBj6hmfm3Se)6rZ{ClOrkQK%RxRpizu z@wZ3XVX0%Ov9M13XJ~8~itCGE*fkv33?F6gfLG4|%ZS5k1d2ZJ6%$O8uP_F_o(K$> z5J&{H3ZtW=g5bk#s9O1GDU9(h>O4ID2;;E?^DPfM%qAeJV0A6u&3V zuTlMNiN*07{Gf|Kzy2@@9gz&^CIkgfLO8S#NDO@JPpccPVUE&;wnakdry15sj(7BR zLEmtnT2=FGzi;G%5heJlh|X(9D5B2?KJY;WU&{haD;dx-AQ8Ob9gYNa5l9H!M?&CZ z&y**xvKXyn&CF4{#GY(e@#ChxvtIkMF8zD>WMLai^vO&=PszR*ivoh7 z8!*{}T`v#NIobi;&7DkjezxJ9-H%{3Q5PzaJFvu~}e_UlH6j+R470TM>8D)HxWqoEt~JQSd2N zK==Gg-{S$lTK*j*;B!F)-%Efw3qH2?p;omj_ai0C1Yg`b!V(0tZ*&gBU%4X<0eRo0 zUGzi~g8yLJ0&eZu~6#U#ULc$D+w?sUBSRu ze@O4)OOJN?mz6XQKBxNI%J1L>-_3^vd?^9u?43Zulxzs^;fu4{{07}9_#iVrJk{@q z5`0!Ba8428nJ>TKyB@%sFw%OgJQnDjgqGhIpYsoViQf-3_$&=z7KaFc2s+h5DCd@^ zi@s?9-$)c-k|8j^uNws@6HemcX#xML5P`E1dxn>< z5IXHl=ShF80M*jYB=}k)&;;KA_@9zK#fqV`=G@N9B2l3PpJ4@N?fJBrUE6Sqx}(>B zA*en2b2nLeH#{!?4hk+Zt|jz6sefg3kGW`+(&u-cpqqN3ENdpUI=2BK=(lCHSjYa6 zY}PC9SM=wycik%JJXdKk!S}S!rQlQiK7#LDlBo&4K`o$rZtr+@!yP;aFUcx@(dqz6 z{64#85xY`}n};$p3J}3RO#|P%dRK}Ud{C8U*RlePC_vNfGy$k0|^CO z)opQ<%i=A-6dl0PcNh z9$;RX{xj_-;ADFtvtSb)d|vI2DWg=Cl~XW6YmFjSrVm@Z-408VljR_5l$*s>O&c1o zFteB(5Br2@W4=WJr`nFgiMC_(DxKnjOqnT8W^G>9J$!zLX1uym2}|^ag<0WC>(+bf zCm8@ATUA9BjE#>`Y9I*zlzsm4g-f(d;8hL&CoRX|M9VQ|mCnqR3=w=r$tE7C_wdnD zvdjvhtk8^+>_PCaziz!yc~!l56i~q*7#xJ^+8UbQ7sEgP9q)VRO)B_=4}5x+PAT}n z0N>|CnN>O!eqR-u(NnUlWKVQ-3|znNI*5z2sfxzZtD1W@mx_ylce^Py5QKk6ED-TJ zjQ~>u-)6H3^?-v}`7`JvzqtSkn~nmjN~aHe)*>!4lg~=YD)IsvqL1qLCE$lyERdd> z0n40Dp@beq_o)=WcfW>={Em(eb`1nK|K{0Oi#k{q!0(ZdrU}cyVrh3Q13TN=aRU6Y zEBU~x(kTO<2fcN@bp1Yn@szChJ8+x@VxprUAwB^T5*(1UAPK^(VN?^ZwQNjBM>~|2 zl~ZaU_z2&_aS?ga^AnUP02gn-^#YI#NQ9YODNNc53GfA1jsUAlCnk6Vwa=){D+AM; zrv1uoGO%gu2CCK`FtmVFV5^AWsx4>FegSTwpe9gP6(u15yy%t5(eEWdP}d8VvZ zv2a8nt)THR$J_*{|n`LLOZio3w)>3*|}!Tn$={KTaBK4tni6TzM%g?YoJy8S70V%B@m;>q$Omr zAFV$ON9qqTFXoVguO<4x*wS(Ig~8a%#xT;wJkJj6RG%X5)YR3%`3o0d0?Xu|yaKw5-YTZ( zbHv4i!85Pr>*Mr*SH{Tb5>O-H@^Dxk93P`jm{h}o6Cl%`<*B&Y zQ$7&{g~lq;*9f3w&+Sr?2>XdV8R>v<%A1%VEsrqR#Ni3D5sA-nQ_` z+JaiQ76I1zlMFg>;zZ|LZ@pD;!wok;dV2aYGNQw@z-JKx!N;fp$$)wR*AKvsmcd`E zpws|J0+5hEd1X&`e>X0#4heRLrs8M&_vsRSJh7Hz(A?4jB_*X$QBeVX{rwu%PGxL5 zJUYyO>$8_W8_^o1N)f08oT>|m%6Gs0_S>aRO-)}AftZ+(kdVv*U#ohSbmXIDK&yac zz~qv5nHh8+NDOa6M7ds|mjKe|@b%X$%od)j?7U3Ng$pFWodkP=fn{V2iatIt(dTlF z!0_-0baZt>3jw{NvI2@rN}##5l^h>3Q03hB!S9a_yLS}-s(3)pN+ZC_NdL0IkdXTL zx#yl+`}pIJ=PX>fFkKJqK%evXGY(w(6MS&-eX1qE$GG6Ku;{Wu(f9EuhuPQVQzm(| zX82U||J1nyUVot}>FDR-b(^j4y9K$mBS!QY4l5iA4WuWL9_=sq*z)u9+g^I!&$xEcN?}{xT1pF#pu3M~0*(iVJ;9LTud1sK$_yW^Jgw2W z1I7&B0PlAH!`W9a{Z=2q;nS)Mtarf(rOG+twaXUU_pc{kC~uld z##Afqn=J~&sHx}z<*}!pdMZ07C+Dj~G~P%AU3e({zSgn*L=Wu0_$T`Hcc=+cosgC0 zWQaZ<4jn(Nd+|po?%`8tg%PrSljp5@%G`dS7C>nYszd@Lh`@4FaSiy-$&)8L_Uzen z?u|FzI7DLqYNGOoV`F1uNGKRPoo?aUB#$O2Yb#Mz>BMb!uNWf6Cr}l?&jlE~tV?F| z1GtAzbPcuS-`9Azbw|-F=l;}us(A>h}Tar|#VZb48NV)l3DFHv+089@M_X+5@V*^;zr)26g#%a$!6 z8p3X~+2TmIlZL}Gj988Y67yS>CwXviuzO-+!rkBB-`z)APft%@U0q#UYin!oxpU`Q z3knL_$uZvvyz#O1cQ0JEU@MDAS!r32g(Q#&!b8VCHV~SDf7c3nc3Q}`gFLDvPgHWe zyL{Z@KK=O%o@@6))uuZ2i;==AiA4w${D24(hy}XgvGrRQtxVji9Hm}Rv(lX+3A_Y2 zBzs-{)1Q1tbd?uKZ-hD4!lwAXLDFl{C5k6CUh!F6h`|rq2oA_CYx%jWuv$zzu}u??;`1aFb7*$fbVySVPlzi0wP4c5KN##@fot`o6fdY z*wbvBj06%BEs%h06C8<9g(6V$d~JTik6;eBupoU7&Ng+4$)p-Fu@p&q%q$q`hThYB zuC*dQRU`o=B=eAf{K%eenVu&E$TJBbZwP*iXcel($5CRMIn=HppwDbR*Ctwm=oPfk ztgEL>mFzJIV4Mu4Vs2qvv<{IoA^J2PqYJBVIoGz&`L*;u&H@{a+`~8531FSZzcnJd zhZ^y5v=~;Vh^c4nb}+t(MId1JiXDe^gSZ_MT^StNdHlzB{Pfl_;P_p-$(}jhtTXtx zheZK-MPZS=WC$0xQKFUz7fFKziKRpx6XIv`BH6qhf$bOf5p;Cox|b^-{!w8rApvq? zm+lOoNr0d=1hG@>Gv1H{w>`fB>9vbB!#CHP1lOK59&?$x`>0gr2qf`07*qoM6N<$f^ByXwEzGB diff --git a/cockatrice/resources/icons/view.svg b/cockatrice/resources/icons/view.svg index e5676d825..19242f058 100644 --- a/cockatrice/resources/icons/view.svg +++ b/cockatrice/resources/icons/view.svg @@ -1,29 +1,4 @@ - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 22679abb1..9c2a91a5b 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,6 +1,6 @@ #include "filter_builder.h" -#include "../interface/pixel_map_generator.h" +#include "../interface/theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -30,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); + QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 7b358010f..6895e7db8 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -1,6 +1,7 @@ #include "player_list_widget.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../../interface/widgets/server/user/user_context_menu.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user_level.h" @@ -67,13 +68,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = themePixmap(QStringLiteral("icons/ready_start")); - notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); - concededIcon = themePixmap(QStringLiteral("icons/conceded")); - playerIcon = loadColorAdjustedPixmap("theme:icons/player"); - judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); - spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start")); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); + concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); + playerIcon = loadThemedIcon(QStringLiteral("icons/player")); + judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); + spectatorIcon = loadThemedIcon(QStringLiteral("icons/spectator")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index b1ca42ee1..3c6cf9d02 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../filters/syntax_help.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../card_dimensions.h" #include "../z_values.h" #include "libcockatrice/utility/macros.h" @@ -93,8 +94,8 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setFocusPolicy(Qt::ClickFocus); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); - searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 2f5c4b6dd..037293af9 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,6 +1,7 @@ #include "pixel_map_generator.h" #include "libcockatrice/network/server/remote/user_level.h" +#include "theme_icon.h" #include "theme_manager.h" #include @@ -15,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -436,33 +436,19 @@ QIcon UserLevelPixmapGenerator::generateIconWithColorOverride(int height, QMap UserLevelPixmapGenerator::iconCache; -QPixmap LockPixmapGenerator::generatePixmap(int height) -{ - - int key = height; - if (pmCache.contains(key)) { - return pmCache.value(key); - } - - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), - QSize(height, height), true); - pmCache.insert(key, pixmap); - return pixmap; -} - -QMap LockPixmapGenerator::pmCache; - QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) { - QString key = QString::number(expanded) + ":" + QString::number(height); + const QString key = QString::number(expanded) + ":" + QString::number(height); if (pmCache.contains(key)) { return pmCache.value(key); } - QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), - QSize(height, height), true); - + // Render through the themed icon engine: it keeps the glyph's aspect ratio, + // follows the palette, and renders at the device pixel ratio instead of + // handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x + // display. + const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); + const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } @@ -520,30 +506,11 @@ QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const Q QHash ManaSymbolPixmapGenerator::masterCache; QHash ManaSymbolPixmapGenerator::scaledCache; -QPixmap loadColorAdjustedPixmap(const QString &name) -{ - // Prefer an authored scheme-qualified variant when one exists for this asset. - const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size())); - if (!variant.isEmpty()) { - return QPixmap(QStringLiteral("theme:") + variant); - } - - // Legacy fallback: runtime-invert for dark mode when no authored variant. - if (qApp->palette().windowText().color().lightness() > 200) { - QImage img(name); - img.invertPixels(); - QPixmap result; - result.convertFromImage(img); - return result; - } else { - return QPixmap(name); - } -} - QPixmap themePixmap(QStringView prefix) { const QString resolved = themeManager->assetPath(prefix); - return QPixmap(QStringLiteral("theme:") + resolved); + const QPixmap sentinel = loadSentinelSvgPixmap(resolved); + return sentinel.isNull() ? QPixmap(QStringLiteral("theme:") + resolved) : sentinel; } void clearPixmapGeneratorCaches() @@ -553,7 +520,6 @@ void clearPixmapGeneratorCaches() PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); - LockPixmapGenerator::clear(); DropdownIconPixmapGenerator::clear(); ManaSymbolPixmapGenerator::clear(); } diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 7c01bb332..951f4231b 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -106,19 +106,6 @@ public: } }; -class LockPixmapGenerator -{ -private: - static QMap pmCache; - -public: - static QPixmap generatePixmap(int height); - static void clear() - { - pmCache.clear(); - } -}; - class DropdownIconPixmapGenerator { private: @@ -160,13 +147,11 @@ public: } }; -QPixmap loadColorAdjustedPixmap(const QString &name); - -// Loads a "theme:" asset (with no file extension in prefix), preferring the +// Returns a "theme:" asset (with no file extension in prefix), preferring the // scheme-qualified variant (prefix-dark / prefix-light, resolved via -// ThemeManager::assetPath) and falling back to the plain asset. Callers load -// the returned path directly. Use for scheme-sensitive pixmaps like -// backgrounds, the card back, and the app logo. +// ThemeManager::assetPath) and falling back to the plain asset. Token-driven +// SVGs resolve their `currentColor` sentinel from the palette. Use for +// scheme-sensitive pixmaps like backgrounds, the card back, and the app logo. QPixmap themePixmap(QStringView prefix); // Clears every PixmapGenerator's static cache so scheme variants are diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp index 730043d95..c8571ab08 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp @@ -1,6 +1,6 @@ #include "abstract_analytics_panel_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../general/display/banner_widget.h" #include "deck_list_statistics_analyzer.h" @@ -28,7 +28,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + configureButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 7ed398535..f25e6c0ff 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -40,8 +40,8 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,13 +83,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); + aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 30fde1595..47d2a1aae 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../playmat/playmat_settings_dialog.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/abstract_tab_deck_editor.h" @@ -248,25 +248,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); aSwapCard = new QAction(QString(), this); - aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); + aSwapCard->setIcon(loadThemedIcon(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index adeb5c3e1..8122d1847 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -5,7 +5,7 @@ #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../tabs/abstract_tab_deck_editor.h" #include "libcockatrice/filters/filter_card.h" @@ -64,11 +64,11 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp index 209dc7273..a1914e1b5 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp @@ -1,6 +1,6 @@ #include "deck_list_history_manager_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../quick_settings/settings_button_widget.h" #include "deck_state_manager.h" #include "libcockatrice/deck_list/deck_list_history_manager.h" @@ -32,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); + aUndo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -41,7 +41,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); + aRedo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -53,7 +53,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); + historyButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 853a00828..daee5f1f9 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,7 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" #include "libcockatrice/utility/macros.h" @@ -38,7 +38,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -46,7 +46,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -121,7 +121,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password"))); + btnForgotPassword->setIcon(loadThemedIcon(QStringLiteral("icons/forgot_password"))); btnForgotPassword->setToolTip(tr("Reset Password")); btnForgotPassword->setFixedWidth(30); connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index 3845029f7..b06481c4b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,6 +1,6 @@ #include "dlg_edit_tokens.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "card_database_display_model.h" #include "libcockatrice/card/card_info.h" @@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index caca5da92..c6e640a9f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,7 +1,7 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "libcockatrice/card/database/card_database.h" @@ -58,28 +58,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); + aTop->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_top"))); aTop->setToolTip(tr("Move selected set to the top")); aTop->setEnabled(false); connect(aTop, &QAction::triggered, this, &WndSets::actTop); setsEditToolBar->addAction(aTop); aUp = new QAction(QString(), this); - aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); + aUp->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_up"))); aUp->setToolTip(tr("Move selected set up")); aUp->setEnabled(false); connect(aUp, &QAction::triggered, this, &WndSets::actUp); setsEditToolBar->addAction(aUp); aDown = new QAction(QString(), this); - aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); + aDown->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_down"))); aDown->setToolTip(tr("Move selected set down")); aDown->setEnabled(false); connect(aDown, &QAction::triggered, this, &WndSets::actDown); setsEditToolBar->addAction(aDown); aBottom = new QAction(QString(), this); - aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); + aBottom->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_bottom"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -89,7 +89,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); + searchField->addAction(loadThemedIcon(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 9e32e87d1..09afee32d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,7 +1,7 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" @@ -34,7 +34,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -42,7 +42,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 005824995..b1a80c86b 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -6,7 +6,7 @@ #include "settings_view.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/theme_manager.h" #include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" @@ -135,7 +135,7 @@ void SettingsView::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); searchEdit->setObjectName("settingsSearchEdit"); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index ea0d2db5a..c6a6d989f 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,12 +1,12 @@ #include "settings_button_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "settings_popup_widget.h" #include #include +#include #include -#include #include #include #include @@ -21,7 +21,7 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + button->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); @@ -49,9 +49,9 @@ void SettingsButtonWidget::removeSettingsWidget(QWidget *toRemove) const popup->removeSettingsWidget(toRemove); } -void SettingsButtonWidget::setButtonIcon(QPixmap iconMap) +void SettingsButtonWidget::setButtonIcon(const QIcon &icon) { - button->setIcon(iconMap); + button->setIcon(icon); } void SettingsButtonWidget::setButtonText(const QString &text) diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h index 015300bf8..56d542d37 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -13,7 +13,7 @@ #include class QHBoxLayout; -class QPixmap; +class QIcon; class QToolButton; class SettingsPopupWidget; @@ -28,7 +28,7 @@ public: void addSettingsWidget(QWidget *toAdd) const; void removeSettingsWidget(QWidget *toRemove) const; - void setButtonIcon(QPixmap iconMap); + void setButtonIcon(const QIcon &icon); void setButtonText(const QString &buttonText); void setCompact(bool compact); bool isCompact() const diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2702a527d..2c0050afe 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,7 +1,7 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" #include "../interface/widgets/tabs/tab_room.h" @@ -130,10 +130,10 @@ GameSelector::GameSelector(AbstractClient *_client, } filterButton = new QPushButton; - filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); + filterButton->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + clearFilterButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 8fed95833..d33526bd1 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -1,6 +1,7 @@ #include "games_model.h" #include "../../../client/settings/cache_settings.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_proxy.h" @@ -85,7 +86,8 @@ const QString GamesModel::getGameCreatedString(const int secs) } GamesModel::GamesModel(const QMap &_rooms, const QMap &_gameTypes, QObject *parent) - : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes) + : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes), + lockIcon(loadThemedIcon(QStringLiteral("icons/lock"))) { } @@ -191,7 +193,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const return result.join(", "); } case Qt::DecorationRole: { - return gameentry.with_password() ? QIcon(LockPixmapGenerator::generatePixmap(13)) : QVariant(); + // One engine per model: it is palette- and theme-aware and + // invalidates itself, so a repaint must not build a new one. + return gameentry.with_password() ? lockIcon : QVariant(); case Qt::TextAlignmentRole: return Qt::AlignLeft; default: diff --git a/cockatrice/src/interface/widgets/server/games_model.h b/cockatrice/src/interface/widgets/server/games_model.h index 9bf54b83a..62862417d 100644 --- a/cockatrice/src/interface/widgets/server/games_model.h +++ b/cockatrice/src/interface/widgets/server/games_model.h @@ -5,6 +5,7 @@ #include "game_type_map.h" #include +#include #include #include #include @@ -32,6 +33,7 @@ private: QList gameList; /**< List of games currently displayed. */ QMap rooms; /**< Map of room IDs to room names. */ QMap gameTypes; /**< Map of room IDs to available game types. */ + QIcon lockIcon; /**< Themed lock for password-protected games. */ static const int NUM_COLS = 8; /**< Number of columns in the table. */ diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp index c131c3cbf..c1db6138d 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp @@ -1,6 +1,6 @@ #include "remote_replay_list_tree_widget.h" -#include "../../../pixel_map_generator.h" +#include "../../../theme_icon.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" @@ -50,7 +50,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 79370f512..e90603858 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -1,7 +1,7 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../dialogs/path_safety_warning.h" #include "update/card_spoiler/spoiler_background_updater.h" @@ -80,19 +80,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); aRateLimit = new QAction(this); - aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + aRateLimit->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit); auto *urlToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 6128a7830..09dd9e9b0 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,7 +1,7 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "libcockatrice/utility/macros.h" @@ -146,15 +146,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index a77c0c2c3..83c2ec585 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" @@ -58,8 +58,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); - btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + btnResetAll->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); + btnClearAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 9f7369435..ae3a9baef 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,7 +1,7 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "../../../cards/additional_info/mana_symbol_widget.h" #include "../../../cards/card_size_widget.h" #include "../../../quick_settings/settings_button_widget.h" @@ -282,7 +282,7 @@ void TabArchidekt::setupFilterWidgets() // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + formatButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 9adda008e..a0e3f2491 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -1,7 +1,7 @@ #include "commander_bracket_widget.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "commander_bracket_service.h" #include "libcockatrice/deck_list/deck_list.h" @@ -49,7 +49,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent bracketInfoButton->setEnabled(false); bracketRefreshButton = new QToolButton(this); - bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + bracketRefreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); bracketRefreshButton->setAutoRaise(true); connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 3dfdc956b..8af9d5e6e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.h" @@ -187,19 +187,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aUpload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -208,24 +208,24 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload); aNewFolder = new QAction(this); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); aPublishDeck = new QAction(this); - aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + aPublishDeck->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index dc14b6e4a..dfde08f8e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" #include "../interface/theme_manager.h" @@ -63,8 +64,8 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c headerLayout->addStretch(1); // Filter/toolbar row, matching the Visual Deck Storage: color identity filter - // first, the search bar stretching in the middle, and the quick settings - // cogwheel at the end. The card size slider lives inside the cogwheel popup. + // first, the search bar stretching in the middle, and the quick-settings button + // at the end. The card size slider lives inside its popup. emptyLabel = new QLabel(tr("This user has not published any decks."), this); emptyLabel->setAlignment(Qt::AlignCenter); emptyLabel->setVisible(false); @@ -79,7 +80,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); quickSettingsWidget->setPublicDecksMode(true); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index d37bff3d8..bd2a69017 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,7 +1,7 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" #include "libcockatrice/protocol/pb/commands.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay.pb.h" @@ -145,17 +145,17 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -207,24 +207,24 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); + aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); + aSubmitReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index f63b77445..329732c64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" #include "../interface/widgets/server/game_link.h" @@ -198,7 +198,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); + chatSettingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index cf71dd7ed..45c320852 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -7,6 +7,7 @@ #include "../../intents/contexts/context_join_room.h" #include "../../intents/intent.h" #include "../../intents/intent_join_server_game.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" @@ -1360,7 +1361,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); + setTabIcon(indexOf(tab), loadThemedIcon(QStringLiteral("icons/tab_changed"))); } if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index 44e6581e9..b09e6da1e 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -24,8 +24,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); - defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); + clearButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); auto *layout = QtUtils::makeLayout(this, 1); layout->addWidget(lineEdit); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 5063cce14..ecb949b67 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -1,6 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/card_database_view.h" #include "../quick_settings/settings_button_widget.h" #include "card_database_display_model.h" @@ -45,13 +45,13 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg sortGroupBox = new QGroupBox(this); filterGroupBox = new QGroupBox(this); - auto scalePixmap = [](const QString &fileName) { return QIcon(QPixmap(fileName)).pixmap({20, 20}); }; + auto scalePixmap = [](const QString &prefix) { return loadThemedIcon(prefix).pixmap({20, 20}); }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("theme:icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("theme:icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter")); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -89,22 +89,22 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 6037418ad..af5c74969 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -4,7 +4,9 @@ #include "../../../filters/filter_tree_model.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/card_size_widget.h" #include "../deck_editor/card_database_view.h" #include "../general/layout_containers/flow_widget.h" @@ -79,8 +81,8 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -154,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 63ef07835..f0c054c3a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -1,6 +1,6 @@ #include "visual_deck_display_options_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/deck_editor_deck_dock_widget.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" @@ -65,7 +65,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); + sortCriteriaButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -119,11 +119,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); break; } } diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 506ffa2b4..0e0b5e4b4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_editor_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" @@ -144,7 +144,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); + searchPushButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [this] { addCardFromSearch(); }); searchLayout->addWidget(searchPushButton); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index f2669f55d..10a3bf33a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_search_widget.h" #include "../../../filters/syntax_help.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -27,9 +27,9 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchBar = new QLineEdit(this); searchBar->setPlaceholderText(tr("Search by filename (or search expression)")); searchBar->setClearButtonEnabled(true); - searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); + searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 4e1d6293f..bbf68adb9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "libcockatrice/card/database/card_database.h" @@ -62,12 +62,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); shareButton = new QToolButton(this); - shareButton->setIcon(themePixmap(QStringLiteral("icons/share"))); + shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); shareButton->setFixedSize(32, 32); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 79a802234..715505926 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -50,6 +50,7 @@ #include "latency_tracker.h" #include "logger.h" #include "pixel_map_generator.h" +#include "theme_icon.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" @@ -387,10 +388,10 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); + aSettings->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); settingsButton->setToolTip(tr("Settings")); settingsButton->setAccessibleName(tr("Settings")); - settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + settingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 93493ee5f..818c1bb16 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -5,12 +5,14 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp ${VERSION_STRING_CPP} theme_migration_test.cpp + theme_icon_render_test.cpp LIBS libcockatrice_settings libcockatrice_network @@ -18,4 +20,6 @@ cockatrice_add_test( libcockatrice_protocol INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src + DEFINITIONS + COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp new file mode 100644 index 000000000..f12db5b16 --- /dev/null +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -0,0 +1,189 @@ +#include "interface/theme_icon.h" +#include "interface/theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps its other + * tests free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "theme_icon_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && pixel.red() == expected.red() && pixel.green() == expected.green() && + pixel.blue() == expected.blue()) { + ++count; + } + } + } + return count; +} + +QByteArray svgWith(const QByteArray &colorToken) +{ + return QByteArrayLiteral(R"()"); +} + +/** + * @brief Restores the global theme search paths and palette after each test. + * + * The icon engine reads both process-wide values, so a failing assertion must + * not leak a temporary theme directory or palette into the next test. + */ +class ThemeIconRenderTest : public ::testing::Test +{ +protected: + void SetUp() override + { + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + savedSearchPaths = QDir::searchPaths(QStringLiteral("theme")); + savedPalette = qApp->palette(); + themeManager = new ThemeManager; + QDir::setSearchPaths(QStringLiteral("theme"), {resourcesDir()}); + } + + void TearDown() override + { + delete themeManager; + themeManager = nullptr; + QDir::setSearchPaths(QStringLiteral("theme"), savedSearchPaths); + qApp->setPalette(savedPalette); + } + + QStringList savedSearchPaths; ///< Search paths captured before the test. + QPalette savedPalette; ///< Application palette captured before the test. +}; + +/** @brief A non-square request must letterbox, not squash, a 24×24 glyph. */ +TEST_F(ThemeIconRenderTest, NonSquareRequestPreservesAspectRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/player")); + ASSERT_FALSE(icon.isNull()); + + EXPECT_EQ(icon.actualSize(QSize(20, 15)), QSize(15, 15)); + + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); +} + +/** @brief The engine must follow palette flips, icon modes, and DPR. */ +TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/search")); + ASSERT_FALSE(icon.isNull()); + + const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0); + ASSERT_FALSE(normal.isNull()); + EXPECT_EQ(normal.size(), QSize(24, 24)); + EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0); + EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0); + + const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0); + ASSERT_FALSE(hidpi.isNull()); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(24, 24)); + + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef"))); + qApp->setPalette(palette); + const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); + EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0); + + QPalette selectedPalette = qApp->palette(); + selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00"))); + qApp->setPalette(selectedPalette); + const QPixmap selected = icon.pixmap(QSize(24, 24), 1.0, QIcon::Selected, QIcon::Off); + EXPECT_GT(countOpaquePixels(selected, QColor(QStringLiteral("#00ff00"))), 0); +} +} // namespace + +TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) +{ + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(Qt::white)); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(24, 24)); + EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0); +} + +TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#000000")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("plain.svg")).isNull()); + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("icons/search.png")).isNull()); +} + +TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("lower.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("currentcolor")); + file.close(); + + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0); +} From e22a75974a49b64beddf7189e35914c812facade Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:42 +0100 Subject: [PATCH 240/247] [Icons] Add an icon asset ratchet test (#7596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Icons] Add an icon asset ratchet test The icon rules were enforced by review only, so the next Inkscape export could reintroduce a baked fill or a physical size without anyone noticing. Add a test that rejects non-24 viewBoxes, physical sizes, editor metadata, hardcoded paints, missing tokens, color-coded filenames, and rasters. Keep the legacy baseline empty so the ratchet can only tighten. * [Tests] Drop the empty icon baseline The legacy icon set is fully paid down, so the ratchet had nothing left to ratchet: BaselineIsWellFormed required the baseline to be empty, which made the baseline file, the stale-entry diff and the entry-format regex unreachable. The rules are enforced directly now. - AssetsFollowTheRules fails on any violation of collectViolations(). - tests/icon_assets/icon-baseline.txt is gone with its reader. --------- Co-authored-by: Lukas Brübach --- tests/CMakeLists.txt | 1 + tests/icon_assets/CMakeLists.txt | 3 + tests/icon_assets/icon_assets_test.cpp | 241 +++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 tests/icon_assets/CMakeLists.txt create mode 100644 tests/icon_assets/icon_assets_test.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8b904d30a..84b9f02bf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -566,6 +566,7 @@ add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) +add_subdirectory(icon_assets) add_subdirectory(loading_from_clipboard) add_subdirectory(model_contract) add_subdirectory(movecard_tests) diff --git a/tests/icon_assets/CMakeLists.txt b/tests/icon_assets/CMakeLists.txt new file mode 100644 index 000000000..da9776f8e --- /dev/null +++ b/tests/icon_assets/CMakeLists.txt @@ -0,0 +1,3 @@ +cockatrice_add_test( + NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" +) diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp new file mode 100644 index 000000000..f0b06d5cc --- /dev/null +++ b/tests/icon_assets/icon_assets_test.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); + +QString iconsDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); +} + +/** @brief True for content art that is allowed to carry full color. */ +bool isContentArt(const QString &relativePath) +{ + return relativePath.startsWith(QStringLiteral("mana/")); +} + +QString violation(const QString &rule, const QString &relativePath) +{ + return rule + QLatin1Char(' ') + relativePath; +} + +/** + * @brief Reads one SVG and returns true when the root carries a usable viewBox. + * + * Also reports malformed XML and editor-only metadata through the out params. + */ +bool inspectSvg(const QString &absolutePath, + bool &validXml, + bool &hasViewBox, + bool &hasEditorMetadata, + bool &hasPhysicalSize) +{ + validXml = false; + hasViewBox = false; + hasEditorMetadata = false; + hasPhysicalSize = false; + + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + QXmlStreamReader reader(&file); + bool sawRoot = false; + + while (!reader.atEnd()) { + reader.readNext(); + + if (reader.hasError()) { + return false; + } + + if (!reader.isStartElement()) { + continue; + } + + if (!sawRoot) { + sawRoot = true; + if (reader.name() != QStringLiteral("svg")) { + return false; + } + const QString viewBox = reader.attributes().value(QStringLiteral("viewBox")).toString().simplified(); + hasViewBox = viewBox == QStringLiteral("0 0 24 24"); + hasPhysicalSize = !reader.attributes().value(QStringLiteral("width")).isEmpty() || + !reader.attributes().value(QStringLiteral("height")).isEmpty(); + } + + if (reader.name() == QStringLiteral("metadata") || reader.name() == QStringLiteral("namedview")) { + hasEditorMetadata = true; + } + + for (const QXmlStreamAttribute &attribute : reader.attributes()) { + const QString prefix = attribute.prefix().toString(); + if (prefix == QStringLiteral("inkscape") || prefix == QStringLiteral("sodipodi") || + prefix == QStringLiteral("dc") || prefix == QStringLiteral("cc") || prefix == QStringLiteral("rdf")) { + hasEditorMetadata = true; + } + } + } + + validXml = sawRoot && !reader.hasError(); + return validXml; +} + +bool isAllowedPaintValue(const QString &value) +{ + const QString normalized = value.trimmed().toLower(); + return normalized.isEmpty() || normalized == QStringLiteral("none") || + normalized == QStringLiteral("currentcolor") || normalized == QStringLiteral("inherit") || + normalized == QStringLiteral("context-fill") || normalized == QStringLiteral("context-stroke") || + normalized.startsWith(QStringLiteral("url(")); +} + +QRegularExpression paintValuePattern() +{ + static const QRegularExpression pattern( + QStringLiteral(R"re((?:fill|stroke|stop-color|color)\s*[:=]\s*["']?\s*([^"'\s;>]+))re"), + QRegularExpression::CaseInsensitiveOption); + return pattern; +} + +bool hasHardcodedColor(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + const QString value = matches.next().captured(1).trimmed(); + if (isAllowedPaintValue(value)) { + continue; + } + const QString normalized = value.toLower(); + if (normalized.startsWith(QLatin1Char('#')) || normalized.contains(QStringLiteral("rgb(")) || + normalized.contains(QStringLiteral("hsl(")) || QColor(value).isValid()) { + return true; + } + } + return false; +} + +bool hasCurrentColorToken(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + if (matches.next().captured(1).trimmed().compare(QStringLiteral("currentColor"), Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +bool isSchemeVariant(const QString &fileName) +{ + return fileName.contains(QStringLiteral("-dark."), Qt::CaseInsensitive) || + fileName.contains(QStringLiteral("-light."), Qt::CaseInsensitive); +} + +bool hasColorInFilename(const QString &fileName) +{ + static const QRegularExpression colorSuffix( + QStringLiteral(R"(_(green|blue|red|yellow|black|white|gray|grey)\.svg$)"), + QRegularExpression::CaseInsensitiveOption); + return colorSuffix.match(fileName).hasMatch(); +} + +/** @brief Collects every current violation as "rule relative/path.svg". */ +QSet collectViolations() +{ + QSet violations; + + QDirIterator it(iconsDir(), QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + const QFileInfo info = it.fileInfo(); + const QString relativePath = QDir(iconsDir()).relativeFilePath(info.absoluteFilePath()); + if (isContentArt(relativePath)) { + continue; + } + if (info.suffix().compare(QStringLiteral("svg"), Qt::CaseInsensitive) != 0) { + violations.insert(violation(QStringLiteral("raster"), relativePath)); + continue; + } + + bool validXml = false; + bool hasViewBox = false; + bool hasEditorMetadata = false; + bool hasPhysicalSize = false; + inspectSvg(info.absoluteFilePath(), validXml, hasViewBox, hasEditorMetadata, hasPhysicalSize); + + if (!validXml) { + violations.insert(violation(QStringLiteral("xml"), relativePath)); + } + if (validXml && !hasViewBox) { + violations.insert(violation(QStringLiteral("viewbox"), relativePath)); + } + if (hasPhysicalSize) { + violations.insert(violation(QStringLiteral("physical-size"), relativePath)); + } + if (hasEditorMetadata) { + violations.insert(violation(QStringLiteral("editor-metadata"), relativePath)); + } + if (hasHardcodedColor(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("hardcoded-color"), relativePath)); + } + if (!hasCurrentColorToken(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("no-token"), relativePath)); + } + if (hasColorInFilename(info.fileName())) { + violations.insert(violation(QStringLiteral("color-filename"), relativePath)); + } + } + + return violations; +} + +QString formatSet(const QSet &entries) +{ + QStringList lines(entries.begin(), entries.end()); + lines.sort(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace + +/** + * @brief Every UI glyph follows the palette-token rules. + * + * The legacy set was paid down icon by icon, so no baseline grandfathers + * violations: a new violation fails right here. Scheme variants + * (`*-dark.svg`/`*-light.svg`) and mana art are exempt inside collectViolations(). + */ +TEST(IconAssets, AssetsFollowTheRules) +{ + ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); + ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); + + const QSet violations = collectViolations(); + EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(violations).toStdString(); +} From ada0cccfbfdbd36dfc14f493eee0fbc959e5047a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:42 +0100 Subject: [PATCH 241/247] [Game] Follow the palette in judge message tags (#7597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Settings] Delete update channels at shutdown SettingsCache owns the stable and beta update channels as raw pointers, and each owns a QNetworkAccessManager, so the process leaves QtNetwork objects alive past QApplication teardown. Delete them from a QCoreApplication::aboutToQuit handler instead of the destructor: SettingsCache is a Q_GLOBAL_STATIC, so ~SettingsCache() runs during static teardown, after QApplication is gone, and destroying the network managers at that point is the post-application window that ASan reports. The hook is installed on the first instance() call that has an application object, since the first access may predate it. * [Game] Follow the palette in judge message tags Judge tags hardcoded a black prefix and a `theme:icons/scales` image inside rich text, where `currentColor` cannot resolve and a palette change leaves the tag unreadable. Render the scales glyph through the themed icon engine, register it as a document image resource, and repaint the log when the palette or theme changes. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/cache_settings.cpp | 28 +++++++++++++++- .../src/client/settings/cache_settings.h | 5 +++ .../game_graphics/log/message_log_widget.cpp | 32 +++++++++++++++++-- .../game_graphics/log/message_log_widget.h | 2 ++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 08eccf1e4..b40e26377 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -6,6 +6,7 @@ #include "shortcuts_settings.h" #include +#include #include #include #include @@ -47,7 +48,32 @@ Q_GLOBAL_STATIC(SettingsCache, settingsCache) SettingsCache &SettingsCache::instance() { - return *settingsCache; // returns a QT managed singleton reference + SettingsCache &cache = *settingsCache; // returns a QT managed singleton reference + // The release channels own QNetworkAccessManagers, which must be destroyed + // while the application object is alive. ~SettingsCache() runs during + // static teardown, after QApplication is gone, so hook the application's + // shutdown instead. Retried here because the first access may predate the + // application object. + cache.installShutdownHook(); + return cache; +} + +void SettingsCache::installShutdownHook() +{ + if (shutdownHookInstalled) { + return; + } + QCoreApplication *application = QCoreApplication::instance(); + if (application == nullptr) { + return; + } + connect(application, &QCoreApplication::aboutToQuit, this, [this] { + for (ReleaseChannel *releaseChannel : std::as_const(releaseChannels)) { + delete releaseChannel; + } + releaseChannels.clear(); + }); + shutdownHookInstalled = true; } QString SettingsCache::getDataPath() diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 9aeb88a18..8ccf6f76b 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -90,8 +90,13 @@ private: QString themeName; QList releaseChannels; + bool shutdownHookInstalled = false; bool isPortableBuild; + // Connects the aboutToQuit handler that deletes the release channels while + // the application object is still alive; a no-op before it exists. + void installShutdownHook(); + [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; void loadPaths(); diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 8b351e022..d526766af 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -4,6 +4,8 @@ #include "../../client/settings/card_counter_settings.h" #include "../../client/sound_engine.h" #include "../../interface/card_localization.h" +#include "../../interface/theme_icon.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/server/chat_view/chat_view.h" #include "../board/card_item.h" #include "../board/translate_counter_name.h" @@ -12,8 +14,12 @@ #include #include #include +#include +#include #include #include +#include +#include #include #include #include @@ -120,6 +126,7 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position void MessageLogWidget::containerProcessingDone() { currentContext = MessageContext_None; + contextJudgeName.clear(); messageSuffix = messagePrefix = QString(); } @@ -855,8 +862,27 @@ void MessageLogWidget::logUndoDrawFailed(PlayerLogic *player) void MessageLogWidget::setContextJudgeName(QString name) { - messagePrefix = QString(""); - messageSuffix = QString(" [ %1]").arg(sanitizeHtml(name)); + contextJudgeName = std::move(name); + refreshJudgeSuffix(); +} + +void MessageLogWidget::refreshJudgeSuffix() +{ + const QPixmap pixmap = loadThemedIcon(QStringLiteral("icons/scales")).pixmap(QSize(24, 24)); + if (!pixmap.isNull()) { + // Rich text cannot resolve `currentColor`; register a palette-rendered + // image so already-appended judge tags repaint on a theme switch. + document()->addResource(QTextDocument::ImageResource, QUrl(QStringLiteral("cockatrice:scales")), pixmap); + document()->markContentsDirty(0, document()->characterCount()); + } + + if (contextJudgeName.isEmpty()) { + return; + } + + messagePrefix = QStringLiteral("").arg(palette().color(QPalette::WindowText).name()); + messageSuffix = + QStringLiteral(" [ %1]").arg(sanitizeHtml(contextJudgeName)); } void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optionalIsBold, QString optionalFontColor) @@ -905,4 +931,6 @@ void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEve MessageLogWidget::MessageLogWidget(TabSupervisor *_tabSupervisor, AbstractGame *_game, QWidget *parent) : ChatView(_tabSupervisor, _game, true, parent), currentContext(MessageContext_None) { + connect(themeManager, &ThemeManager::paletteChanged, this, &MessageLogWidget::refreshJudgeSuffix); + connect(themeManager, &ThemeManager::themeChanged, this, &MessageLogWidget::refreshJudgeSuffix); } diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 5632f5af8..3ec5ff93c 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -39,8 +39,10 @@ private: MessageContext currentContext; QString messagePrefix, messageSuffix; + QString contextJudgeName; static QPair getFromStr(CardZoneLogic *zone, QString cardName, int position, bool ownerChange); + void refreshJudgeSuffix(); public: void connectToPlayerEventHandler(PlayerEventHandler *player); From df74917cb0d902371ce6eb4d334dbbc9c667ab66 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:43 +0100 Subject: [PATCH 242/247] [PrintingSelector] Load the pin badge through the themed icon engine (#7598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge rendered `theme:icons/pin` through the pixmap generator, which resolved `currentColor` once and cached the result, so the glyph stayed at its first palette and was rasterized at DPR 1. - Render the glyph through `loadThemedIcon`, at the label's device pixel ratio, and re-render it on palette and theme changes. - Compose it onto a rounded backdrop filled with `Window` and outlined with `WindowText`. The badge floats on card art rather than on a palette surface, so a bare outline vanishes into art of the same lightness; the backdrop keeps it readable on every printing, and both roles still follow the palette. Co-authored-by: Lukas Brübach --- .../printing_selector_card_overlay_widget.cpp | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 94acdbdce..a5177a6bc 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -2,6 +2,8 @@ #include "../../../client/settings/cache_settings.h" #include "../../card_picture_loader/card_picture_loader.h" +#include "../../theme_icon.h" +#include "../../theme_manager.h" #include "../cards/card_info_picture_widget.h" #include "../tabs/abstract_tab_deck_editor.h" #include "all_zones_card_amount_widget.h" @@ -12,16 +14,19 @@ #include #include +#include #include #include #include #include -#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -36,7 +41,6 @@ #include #include #include -#include #include #include #include @@ -120,6 +124,12 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa mainLayout->addWidget(cardInfoPicture); initializePinBadge(); + auto refreshPinBadge = [this] { + initializePinBadge(); + updatePinBadgeVisibility(); + }; + connect(themeManager, &ThemeManager::paletteChanged, this, refreshPinBadge); + connect(themeManager, &ThemeManager::themeChanged, this, refreshPinBadge); // Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps // it a frameless, non-activating top-level window despite the parent). @@ -401,10 +411,55 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) } /** - * @brief Initializes the pin badge overlay and loads its icon with DPI-aware rasterization. + * @brief Composes the pin badge: the glyph over a rounded palette backdrop. * - * The icon is rasterized once using the label's device pixel ratio so it stays crisp on HiDPI - * displays. The resulting pixmap is cached on the QLabel and simply shown/hidden when needed. + * The badge floats on card art rather than on a palette surface, so a bare glyph + * colored WindowText is not enough: a thin dark stroke disappears on dark art in a + * light palette, and a thin light stroke disappears on light art in a dark palette. + * A Window-filled rounded rect with a WindowText outline gives the glyph a solid + * backing, and the outline guarantees the badge edge contrasts with the art even when + * the fill matches it. Both roles follow the palette, like the glyph. + * + * @param logicalSize The badge's size in logical pixels. + * @param devicePixelRatio The label's device pixel ratio, applied to the pixmap so it + * stays crisp on HiDPI displays. + * @param palette The palette the glyph's WindowText resolves against. + */ +static QPixmap composePinBadgePixmap(const QSize &logicalSize, qreal devicePixelRatio, const QPalette &palette) +{ + // Inset the glyph so the backdrop reads as a badge around it, not as a border. + const int inset = qRound(logicalSize.width() * 0.16); + const QSize glyphSize = logicalSize - QSize(inset * 2, inset * 2); + const QPixmap glyph = loadThemedIcon(QStringLiteral("icons/pin")).pixmap(glyphSize, devicePixelRatio); + if (glyph.isNull()) { + // Let the caller fall back to the textual badge instead of drawing an empty panel. + return QPixmap(); + } + + QPixmap badge(logicalSize * devicePixelRatio); + badge.setDevicePixelRatio(devicePixelRatio); + badge.fill(Qt::transparent); + + QPainter painter(&badge); + painter.setRenderHint(QPainter::Antialiasing); + + // Half-pixel inset keeps the 1.5px outline inside the pixmap instead of clipping it. + const QRectF box(QPointF(0.5, 0.5), QSizeF(logicalSize) - QSizeF(1, 1)); + painter.setPen(QPen(palette.color(QPalette::WindowText), 1.5)); + painter.setBrush(palette.color(QPalette::Window)); + painter.drawRoundedRect(box, 8, 8); + painter.drawPixmap(QPointF(inset, inset), glyph); + + return badge; +} + +/** + * @brief Initializes the pin badge overlay and loads a DPI-aware icon pixmap. + * + * The glyph is drawn onto a rounded palette backdrop so the badge stays readable on + * card art, and it is rasterized at the label's device pixel ratio so it stays crisp on + * HiDPI displays. It is re-rendered when the palette or theme changes so the badge + * follows the surrounding UI instead of freezing at its first colors. * If the SVG cannot be read, a textual fallback badge is created instead. */ void PrintingSelectorCardOverlayWidget::initializePinBadge() @@ -420,24 +475,13 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() } bool pinLoaded = false; - QImageReader pinReader(QStringLiteral("theme:icons/pin")); - - if (pinReader.canRead()) { - // Rasterize a 64×64 logical icon so it has a consistent size regardless of card scaling. - const QSize targetSize(64, 64); - const qreal dpr = pinBadge->devicePixelRatioF(); - const QSize rasterSize(qMax(1, qCeil(targetSize.width() * dpr)), qMax(1, qCeil(targetSize.height() * dpr))); - pinReader.setScaledSize(rasterSize); - const QImage pinImage = pinReader.read(); - if (!pinImage.isNull()) { - // Tag the pixmap with the same DPR so Qt renders it at the correct physical size. - QPixmap pinPix = QPixmap::fromImage(pinImage); - pinPix.setDevicePixelRatio(dpr); - pinBadge->setPixmap(pinPix); - pinBadge->setFixedSize(targetSize); - pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); - pinLoaded = true; - } + const QSize targetSize(64, 64); + const QPixmap pinPix = composePinBadgePixmap(targetSize, pinBadge->devicePixelRatioF(), pinBadge->palette()); + if (!pinPix.isNull()) { + pinBadge->setPixmap(pinPix); + pinBadge->setFixedSize(targetSize); + pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); + pinLoaded = true; } if (!pinLoaded) { From 64c780933212c5a753013edaa09800af4a455cc1 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:49:20 +0100 Subject: [PATCH 243/247] [DlgCreateGame] Heap-allocate create game dialog to prevent crash on parent deletion (#7777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [DlgCreateGame] Heap-allocate create game dialog to prevent crash on parent deletion * [Tests] Run the short-write cases without forking a death test The three cases that force a write failure - CardDatabaseTest.Xml4ShortWriteKeepsInstalledFile, OracleDatabaseWriter.ShortWriteKeepsPreviouslyInstalledDatabase and SpoilerFileTest.ShortWriteLeavesExistingFileIntact - all forked a child, lowered RLIMIT_FSIZE and ignored SIGXFSZ. That child is killed by SIGSEGV on the macOS runners, so all three have been red on every macOS leg of master since they landed, while the same cases pass on Linux. Cap the soft limit in the test process instead: ctest already runs one case per process, and the guard restores the limit and the SIGXFSZ handler, so a whole binary keeps working. Same real EFBIG failure, no fork, and macOS keeps its coverage of the writers. * [CI] Derive DEBUG_TEST and RUN_TESTS in the nightly job The shared steps gate on both, but build-linux-nightly never set them, so its coverage leg skipped "Build debug and test" and then failed collecting a report from an empty build/ directory on every nightly and tag run. continue-on-error kept the run green, so the coverage artifact silently never existed. Derive both exactly as the pull request job does. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .github/workflows/desktop-build.yml | 6 ++ .../widgets/server/game_selector.cpp | 15 ++-- .../src/interface/widgets/tabs/tab_game.cpp | 6 +- tests/carddatabase/carddatabase_test.cpp | 30 ++----- tests/oracle/oracle_importer_test.cpp | 31 +++---- tests/spoiler_file_test.cpp | 32 ++----- tests/support/scoped_file_size_limit.h | 83 +++++++++++++++++++ 7 files changed, 132 insertions(+), 71 deletions(-) create mode 100644 tests/support/scoped_file_size_limit.h diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 4ac28d3a5..5a306e023 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -518,6 +518,12 @@ jobs: CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} + # The shared steps below gate on these, so they have to be derived here too. Left + # undefined they silently skipped the build the leg exists for: the coverage leg + # found build/ empty and its report collection failed on every nightly and tag run. + # Same derivation as the pull request job above. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} steps: *linux-steps build-vcpkg: diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2c0050afe..468095adb 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -266,9 +266,13 @@ void GameSelector::actCreate() return; } - DlgCreateGame dlg(room, room->getGameTypes(), this); - dlg.exec(); - updateTitle(); + // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the + // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated + // child would then be deleted by Qt and crash. + auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle); + dlg->open(); } void GameSelector::checkResponse(const Response &response) @@ -362,8 +366,9 @@ void GameSelector::customContextMenu(const QPoint &point) const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); const QMap &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id()); - DlgCreateGame dlg(gameInfo, gameTypes, this); - dlg.exec(); + auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->open(); }); QAction copyLink(tr("Cop&y game link")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index ac9edd1ba..01d021907 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -570,8 +570,10 @@ void TabGame::adminLockChanged(bool lock) void TabGame::actGameInfo() { - DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); - dlg.exec(); + // Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object. + auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->open(); } void TabGame::actCopyGameLink() diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index b54205f30..350d9be7e 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,3 +1,4 @@ +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -36,12 +37,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { @@ -247,21 +242,14 @@ TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) ASSERT_EQ(installed.write("installed database"), 18); installed.close(); - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - CockatriceXml4Parser writer(&controller); - _exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + CockatriceXml4Parser writer(&controller); + EXPECT_FALSE(writer.saveToFile({}, sets, cards, fileName)); ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); EXPECT_EQ(installed.readAll(), "installed database"); diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 259bae087..1b4e2d915 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" #include "gtest/gtest.h" @@ -33,12 +34,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - class OracleImporterTest : public ::testing::Test { protected: @@ -1336,20 +1331,16 @@ TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) original.close(); const QByteArray data = ""; - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - _exit(OracleDatabase::saveXml(path, data) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); EXPECT_EQ(original.readAll(), "installed database"); } diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp index 9944f95bd..ecdc3285f 100644 --- a/tests/spoiler_file_test.cpp +++ b/tests/spoiler_file_test.cpp @@ -1,4 +1,5 @@ #include "client/network/update/card_spoiler/spoiler_file.h" +#include "support/scoped_file_size_limit.h" #include #include @@ -6,12 +7,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { class SpoilerFileTest : public ::testing::Test @@ -123,25 +118,16 @@ TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) #ifdef Q_OS_UNIX TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact) { - // Limit only the child process: a real short write exercises QSaveFile's - // buffered flush failure without depending on disk capacity or file ownership. const QByteArray changed = ""; - EXPECT_EXIT( - { - struct rlimit limit; - if (getrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - limit.rlim_cur = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(3); - } - std::signal(SIGXFSZ, SIG_IGN); - const auto result = save(changed); - _exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1); - }, - ::testing::ExitedWithCode(0), ""); + // A real short write exercises QSaveFile's buffered flush failure without depending + // on disk capacity. The cap is scoped rather than forked, because a death-test child + // under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); EXPECT_EQ(read(), original); EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); } diff --git a/tests/support/scoped_file_size_limit.h b/tests/support/scoped_file_size_limit.h new file mode 100644 index 000000000..1c1f3d939 --- /dev/null +++ b/tests/support/scoped_file_size_limit.h @@ -0,0 +1,83 @@ +#ifndef COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H +#define COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H + +#include + +#ifdef Q_OS_UNIX + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Caps how large a single file may grow, so a writer's short-write path is reachable. + * + * Lowering RLIMIT_FSIZE makes the next write past the cap fail with EFBIG instead of + * filling a filesystem, which is the only way to fail an install step mid-write without + * depending on free disk space or file ownership. Only the soft limit is lowered, so the + * destructor can hand the process back exactly as it found it: ctest runs one case per + * process, but a whole test binary must not carry the cap into its neighbours. + * + * Do not use this inside a gtest death test. A forked child under RLIMIT_FSIZE is killed + * by SIGSEGV on the macOS runners, while the same case passes on Linux, so the fork is + * what breaks - not the writer. The guard alone isolates the failure just as well. + */ +class ScopedFileSizeLimit +{ +public: + explicit ScopedFileSizeLimit(rlim_t maxBytes) + { + if (getrlimit(RLIMIT_FSIZE, &previousLimit) != 0) { + return; + } + + struct sigaction ignore = {}; + ignore.sa_handler = SIG_IGN; + sigemptyset(&ignore.sa_mask); + if (sigaction(SIGXFSZ, &ignore, &previousAction) != 0) { + return; + } + actionSaved = true; + + struct rlimit capped = previousLimit; + capped.rlim_cur = std::min(maxBytes, previousLimit.rlim_max); + if (setrlimit(RLIMIT_FSIZE, &capped) != 0) { + return; + } + active = true; + } + + ~ScopedFileSizeLimit() + { + if (active) { + setrlimit(RLIMIT_FSIZE, &previousLimit); + } + if (actionSaved) { + sigaction(SIGXFSZ, &previousAction, nullptr); + } + } + + ScopedFileSizeLimit(const ScopedFileSizeLimit &) = delete; + ScopedFileSizeLimit &operator=(const ScopedFileSizeLimit &) = delete; + + /** + * @brief Whether the cap is in force; a test that needs the failure must assert this. + */ + [[nodiscard]] bool isValid() const + { + return active; + } + +private: + bool active = false; + bool actionSaved = false; + struct rlimit previousLimit = {}; + struct sigaction previousAction = {}; +}; +} // namespace test_support + +#endif // Q_OS_UNIX + +#endif // COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H From 74686d173637a287895149f009a8c35dfd0366df Mon Sep 17 00:00:00 2001 From: Jurriaan Date: Thu, 5 Nov 2026 12:40:32 +0100 Subject: [PATCH 244/247] [Client] Added an exclude and color identity filter for the visual database display (#7587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Add exclude and color identity filters to the visual database display * [Filters] Extract the color filter mode derivation into libcockatrice_filters The mode table and the 'which mode produced this filter set' derivation are filter semantics, not widget code, so they belong next to CardFilter rather than in an anonymous namespace in the client. Moving them also makes them testable: filter_mode_test pins every mode, the Custom fallback and the 'keep the live mode when it still fits' rule. * [Tests] Cover the negated color identity query ci! read the camelCase colorIdentity property instead of the lowercase coloridentity key, so it matched the empty string and rejected every card. The new case uses a card whose colors and color identity differ, so it fails on the old lookup and passes on the fixed one. --------- Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- ...l_database_display_color_filter_widget.cpp | 206 +++++++++--------- ...ual_database_display_color_filter_widget.h | 13 +- .../visual_database_display_widget.cpp | 1 + .../libcockatrice/card/card_info.cpp | 4 + .../libcockatrice/card/card_info.h | 1 + libcockatrice_filters/CMakeLists.txt | 8 +- .../libcockatrice/filters/filter_card.cpp | 2 + .../libcockatrice/filters/filter_card.h | 1 + .../libcockatrice/filters/filter_mode.cpp | 77 +++++++ .../libcockatrice/filters/filter_mode.h | 62 ++++++ .../libcockatrice/filters/filter_string.cpp | 4 +- .../libcockatrice/filters/filter_tree.cpp | 60 +++-- .../libcockatrice/filters/filter_tree.h | 1 + tests/carddatabase/CMakeLists.txt | 34 ++- tests/carddatabase/filter_mode_test.cpp | 147 +++++++++++++ tests/carddatabase/filter_string_test.cpp | 19 ++ tests/carddatabase/filter_tree_test.cpp | 204 +++++++++++++++++ 17 files changed, 703 insertions(+), 141 deletions(-) create mode 100644 libcockatrice_filters/libcockatrice/filters/filter_mode.cpp create mode 100644 libcockatrice_filters/libcockatrice/filters/filter_mode.h create mode 100644 tests/carddatabase/filter_mode_test.cpp create mode 100644 tests/carddatabase/filter_tree_test.cpp diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index 464d62e4a..46330ac46 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -15,9 +15,23 @@ #include #include +namespace +{ + +/** @brief Remove the filters that apply to the given color and attribute from the given model. */ +static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr) +{ + for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) { + if (filter->term() == color) { + filterModel->removeFilter(filter); + } + } +} +} // namespace + /** - * This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter - * model. + * This widget provides a graphical control element for the CardFilter::Attr::AttrColor and + * CardFilter::Attr::AttrColorIdentity filters applied to the filter model. * @param parent The Qt Widget that this widget will parent to * @param _filterModel The filter model that this widget will manipulate */ @@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(true); modeComboBox->clear(); - modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes)); + modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes)); modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude)); + modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); + modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity)); + + modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"), + Qt::ToolTipRole); + modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole); + + if (currentMode == FilterMode::Custom) { + addCustomFilterModeItem(); + } modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter")); + modeComboBox->setAccessibleName(tr("Color filter mode")); // Restore current mode const int index = modeComboBox->findData(QVariant::fromValue(currentMode)); @@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(false); } +void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode) +{ + int index = modeComboBox->findData(QVariant::fromValue(filterMode)); + + if (index < 0 && filterMode == FilterMode::Custom) { + index = modeComboBox->count(); + addCustomFilterModeItem(); + } + + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } +} + +void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem() +{ + int customIndex = modeComboBox->count(); + modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom)); + modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"), + Qt::ToolTipRole); +} + void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active) { if (active) { @@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color) { - QString colorString = color; - QString typeStr; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove previous filters + removeFilters(filterModel, color, info.attr); - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; - - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add actual filter - - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; + if (info.includeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.includeType, info.attr)); } } void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color) { - QString colorString = color; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove inclusion filters - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; + removeFilters(filterModel, color, info.attr); - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add exclusion filters if the mode demands it - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - // No exclusion in Includes mode - break; + // Add exclusion filters if the mode demands it. + if (info.excludeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr)); } } @@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode() filterModel->filterTree()->blockSignals(true); filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity); const QList manaSymbolWidgets = findChildren(); @@ -211,52 +212,53 @@ QList VisualDatabaseDisplayColorFilterWidget::getActiveColors() void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel() { - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); + QList filters; + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor)); + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity)); - QList manaSymbolWidgets = findChildren(); + // Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that + // produced it (or Custom when none could have). + currentMode = deriveFilterMode(filters, currentMode); - for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) { + // Update the combobox. Add a Custom item only if we have a custom filter mode. + int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom)); + modeComboBox->blockSignals(true); + if (currentMode == FilterMode::Custom) { + if (customIndex < 0) { + customIndex = modeComboBox->count(); + addCustomFilterModeItem(); + } + modeComboBox->setCurrentIndex(customIndex); + } else { + if (customIndex >= 0) { + modeComboBox->removeItem(customIndex); + } + int index = modeComboBox->findData(QVariant::fromValue(currentMode)); + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } + } + modeComboBox->blockSignals(false); + + // Update the mana symbol widgets by looking for the current mode's include or exclude filters. + const FilterModeInfo modeInfo = getFilterModeInfo(currentMode); + + const bool useExclude = + modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd; + const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType; + + filters = filterModel->getFiltersOfType(modeInfo.attr); + + for (ManaSymbolWidget *manaSymbolWidget : findChildren()) { bool found = false; - for (const CardFilter *filter : allColorFilters) { - if (manaSymbolWidget->getSymbolChar() == filter->term()) { - switch (currentMode) { - case FilterMode::ExactMatch: - switch (filter->type()) { - case CardFilter::Type::TypeAnd: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::Includes: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::IncludeExclude: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - } + for (const CardFilter *filter : filters) { + if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) { found = true; + break; } } - if (!found) { - setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false); - } + manaSymbolWidget->setColorActive(useExclude ? !found : found); + manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom); } } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 184f1f96c..561c92d09 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -10,8 +10,8 @@ #include #include #include -#include #include +#include #include #include @@ -38,15 +38,6 @@ private: int circleDiameter; }; -enum class FilterMode -{ - ExactMatch, // Only selected colors are included, all others are excluded. - Includes, // Include selected colors (OR condition). - IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT). -}; - -Q_DECLARE_METATYPE(FilterMode) - class VisualDatabaseDisplayColorFilterWidget : public QWidget { Q_OBJECT @@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget public: explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel); void retranslateUi(); + void setFilterMode(FilterMode filterMode); signals: void filterModeChanged(FilterMode filterMode); private slots: + void addCustomFilterModeItem(); void handleColorToggled(QChar color, bool active); void addFilter(QChar color); void removeFilter(QChar color); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index af5c74969..6d9b28619 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); + colorFilterWidget->setFilterMode(FilterMode::Includes); filterModel->clear(); filterModel->blockSignals(false); filterModel->filterTree()->blockSignals(false); diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 90bfd3f44..36f0cdeb8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -316,6 +316,10 @@ void CardInfo::setColors(const QString &value) { setProperty(Mtg::Colors, value); } +QString CardInfo::getColorIdentity() const +{ + return getProperty(Mtg::ColorIdentity); +} QString CardInfo::getLoyalty() const { return getProperty(Mtg::Loyalty); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 5452ef98b..075b5b264 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -469,6 +469,7 @@ public: [[nodiscard]] QString getCmc() const; [[nodiscard]] QString getColors() const; void setColors(const QString &value); + [[nodiscard]] QString getColorIdentity() const; [[nodiscard]] QString getLoyalty() const; [[nodiscard]] QString getMainCardType() const; [[nodiscard]] QString getManaCost() const; diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 070c04637..1b4303fc9 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,10 +1,10 @@ -set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h - libcockatrice/filters/filter_tree.h +set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_mode.h + libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) add_library( - libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp - libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_mode.cpp + libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 26cddd53c..8af39c098 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -69,6 +69,8 @@ const QString CardFilter::attrName(Attr a) return tr("Type"); case AttrColor: return tr("Color"); + case AttrColorIdentity: + return tr("Color Identity"); case AttrText: return tr("Text"); case AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index dbeffd18f..bc613fc9c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -32,6 +32,7 @@ public: { AttrCmc = 0, AttrColor, + AttrColorIdentity, AttrLoyalty, AttrManaCost, AttrName, diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp new file mode 100644 index 000000000..356ccf608 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp @@ -0,0 +1,77 @@ +/** + * @file filter_mode.cpp + * @ingroup Filters + */ + +#include "libcockatrice/filters/filter_mode.h" + +#include + +FilterModeInfo getFilterModeInfo(FilterMode filterMode) +{ + switch (filterMode) { + case FilterMode::Includes: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::Excludes: + return {CardFilter::Type::TypeAndNot, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::IncludeExclude: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ExactMatch: + return {CardFilter::Type::TypeAnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ColorIdentity: + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity}; + case FilterMode::Custom: + case FilterMode::End: + break; + } + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrEnd}; +} + +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode) +{ + constexpr int modeCount = static_cast(FilterMode::End); + bool modeImpossible[modeCount] = {}; + const QString colors = "WUBRG"; + + for (int i = 0; i < modeCount; ++i) { + const FilterModeInfo modeInfo = getFilterModeInfo(static_cast(i)); + + // Remove the modes that cannot create one of the filters. + for (const CardFilter *filter : filters) { + if (filter->attr() != modeInfo.attr || + (filter->type() != modeInfo.includeType && filter->type() != modeInfo.excludeType)) { + modeImpossible[i] = true; + break; + } + } + + // Modes that have both an include and an exclude filter should always have a filter for each color. + if (modeInfo.includeType != CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd && + !modeImpossible[i]) { + for (const QChar &color : colors) { + int count = 0; + for (const CardFilter *filter : filters) { + count += filter->term() == color; + } + if (count != 1) { + modeImpossible[i] = true; + break; + } + } + } + } + + // Keep the current mode if it can produce the filters, so that a live mode is never swapped for another. + // Switching to and from the custom mode is always possible. + if (!modeImpossible[static_cast(currentMode)] && currentMode != FilterMode::Custom) { + return currentMode; + } + + for (int i = 0; i < modeCount; ++i) { + if (!modeImpossible[i] || i == static_cast(FilterMode::Custom)) { + return static_cast(i); + } + } + + return FilterMode::Custom; +} diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.h b/libcockatrice_filters/libcockatrice/filters/filter_mode.h new file mode 100644 index 000000000..f7662d662 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.h @@ -0,0 +1,62 @@ +/** + * @file filter_mode.h + * @ingroup Filters + */ + +#ifndef LIBCOCKATRICE_FILTERS_FILTER_MODE_H +#define LIBCOCKATRICE_FILTERS_FILTER_MODE_H + +#include "libcockatrice/filters/filter_card.h" + +#include +#include +#include + +/** + * @brief The way selected and unselected colors are combined into card filters. + */ +enum class FilterMode +{ + Includes = 0, // Include selected colors (OR condition). + Excludes, // Exclude selected colors (AND NOT). + IncludeExclude, // Include selected colors (OR) and exclude unselected colors (AND NOT). + ExactMatch, // Only selected colors are included, all others are excluded. + ColorIdentity, // Keep cards whose color identity is a subset of the selected colors (stored as AND NOT for each + // unselected color). + Custom, // The color filters do not match any of the above modes. Not included in the combo box. + End +}; + +Q_DECLARE_METATYPE(FilterMode) + +/** + * @brief The filters a single color contributes in a given mode. + */ +struct FilterModeInfo +{ + // When a color symbol is enabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type includeType; + + // When a color symbol is disabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type excludeType; + + // The attribute to filter for when adding the filters. + CardFilter::Attr attr; +}; + +/** @brief Return the filters the given mode contributes for one color. */ +FilterModeInfo getFilterModeInfo(FilterMode filterMode); + +/** + * @brief Determine the mode that produced the given color filters. + * + * Returns @p currentMode unchanged when that mode can still produce @p filters, so that a live mode is never + * replaced by an equally valid one. Otherwise returns the first mode that can produce them, falling back to + * FilterMode::Custom when none can. + * + * @param filters The filters on CardFilter::Attr::AttrColor and CardFilter::Attr::AttrColorIdentity. + * @param currentMode The mode currently selected in the UI. + */ +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode); + +#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 081ada824..1bcb1646b 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -409,7 +409,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -427,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index f12f87181..817c7298d 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -176,23 +176,24 @@ FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(st lowercaseTerm = term.toLower(); switch (attr()) { - case CardFilter::AttrColor: { - QString converted = term.trimmed(); + case CardFilter::AttrColor: + case CardFilter::AttrColorIdentity: { + QString converted = term.trimmed().toLower(); - converted.replace("green", "g", Qt::CaseInsensitive); - converted.replace("grn", "g", Qt::CaseInsensitive); - converted.replace("blue", "u", Qt::CaseInsensitive); - converted.replace("blu", "u", Qt::CaseInsensitive); - converted.replace("black", "b", Qt::CaseInsensitive); - converted.replace("blk", "b", Qt::CaseInsensitive); - converted.replace("red", "r", Qt::CaseInsensitive); - converted.replace("white", "w", Qt::CaseInsensitive); - converted.replace("wht", "w", Qt::CaseInsensitive); - converted.replace("colorless", "c", Qt::CaseInsensitive); - converted.replace("colourless", "c", Qt::CaseInsensitive); - converted.replace("none", "c", Qt::CaseInsensitive); + converted.replace("green", "g"); + converted.replace("grn", "g"); + converted.replace("blue", "u"); + converted.replace("blu", "u"); + converted.replace("black", "b"); + converted.replace("blk", "b"); + converted.replace("red", "r"); + converted.replace("white", "w"); + converted.replace("wht", "w"); + converted.replace("colorless", "c"); + converted.replace("colourless", "c"); + converted.replace("none", "c"); - converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + converted.replace(QString(" "), QString("")); convertedColorTerm = converted; break; @@ -358,12 +359,17 @@ bool FilterItem::acceptSubType(const CardInfoPtr &info) const return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr &info) const +/** + * @brief Return true when every letter of the given term appears in the given colors string. + * If the term is equal to "c", then only return true if the color string is empty. + * + * @param term The letters which should be included, or "c" + * @param colors The string to check for letters in + */ +static bool matchesColor(const QString &term, const QString &colors) { - const QString colors = info->getColors(); - // Colorless card filter - if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { + if (term == "c" && colors.length() < 1) { return true; } @@ -372,13 +378,23 @@ bool FilterItem::acceptColor(const CardInfoPtr &info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (const QChar &color : convertedColorTerm) { + for (const QChar &color : term) { if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == convertedColorTerm.length(); + return match_count == term.length(); +} + +bool FilterItem::acceptColor(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColors()); +} + +bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColorIdentity()); } bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const @@ -540,6 +556,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); + case CardFilter::AttrColorIdentity: + return acceptColorIdentity(info); case CardFilter::AttrText: return acceptText(info, searchLanguage); case CardFilter::AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 85a57304d..f1a5803e5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -244,6 +244,7 @@ public: [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColorIdentity(const CardInfoPtr &info) const; [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 7d83640a1..012081fd2 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,10 +21,12 @@ cockatrice_add_test( ) # ------------------------ -# Filter String Test -# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) +# Guard to match condition for libcockatrice_filters in the root CMakeLists.txt # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) + # ------------------------ + # Filter String Test + # ------------------------ cockatrice_add_test( NAME filter_string_test @@ -36,6 +38,34 @@ if(WITH_ORACLE OR WITH_CLIENT) libcockatrice_filters ) + # ------------------------ + # Filter Tree Test + # ------------------------ + cockatrice_add_test( + NAME + filter_tree_test + SOURCES + ${VERSION_STRING_CPP} + filter_tree_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + + # ------------------------ + # Filter Mode Test + # ------------------------ + cockatrice_add_test( + NAME + filter_mode_test + SOURCES + ${VERSION_STRING_CPP} + filter_mode_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + # ------------------------ # Visual Display Model Test # ------------------------ diff --git a/tests/carddatabase/filter_mode_test.cpp b/tests/carddatabase/filter_mode_test.cpp new file mode 100644 index 000000000..3fec7e25d --- /dev/null +++ b/tests/carddatabase/filter_mode_test.cpp @@ -0,0 +1,147 @@ +#include "libcockatrice/filters/filter_mode.h" + +#include + +class FilterModeTest : public ::testing::Test +{ +protected: + ~FilterModeTest() override + { + for (CardFilter *filter : owned) { + delete filter; + } + } + + /** @brief Create a filter owned by the fixture and append it to the set under test. */ + const CardFilter * + add(const QString &term, CardFilter::Type type, CardFilter::Attr attr = CardFilter::Attr::AttrColor) + { + owned.append(new CardFilter(term, type, attr)); + filters.append(owned.last()); + return owned.last(); + } + + QList owned; + QList filters; +}; + +TEST(FilterModeInfoTest, EachModeDescribesItsOwnFilters) +{ + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).excludeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).attr, CardFilter::Attr::AttrColor); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).includeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).excludeType, CardFilter::Type::TypeEnd); + + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).excludeType, CardFilter::Type::TypeAndNot); + + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).includeType, CardFilter::Type::TypeAnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).excludeType, CardFilter::Type::TypeAndNot); + + // Color identity only excludes: the selected colors must be the identity, so nothing is included positively. + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).includeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).excludeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).attr, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Custom).attr, CardFilter::Attr::AttrEnd); +} + +TEST_F(FilterModeTest, EmptyFilterSetKeepsACompatibleMode) +{ + // Includes and Color identity both allow an empty set, so the live mode is not swapped for another. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +// Include / Exclude and Exact match always hold one filter per color, so an empty set cannot have come from +// them and falls back to the first mode that can describe it. +TEST_F(FilterModeTest, EmptyFilterSetFallsBackWhenTheModeNeedsOneFilterPerColor) +{ + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::Includes); +} + +TEST_F(FilterModeTest, IncludesFromOrFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + // A single OR filter cannot come from Excludes, so the first mode that can produce it is selected. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Includes); +} + +TEST_F(FilterModeTest, ExcludesFromAndNotFilters) +{ + add("W", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Excludes); + // Includes needs an OR or an end filter, so an AND NOT filter selects Excludes. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} + +TEST_F(FilterModeTest, IncludeExcludeNeedsOneFilterPerColor) +{ + add("W", CardFilter::Type::TypeOr); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::IncludeExclude); +} + +TEST_F(FilterModeTest, IncludeExcludeRejectsAnIncompleteColorSet) +{ + add("W", CardFilter::Type::TypeOr); + add("U", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Custom); +} + +TEST_F(FilterModeTest, ExactMatchFromAndAndAndNotFilters) +{ + add("W", CardFilter::Type::TypeAnd); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); +} + +TEST_F(FilterModeTest, ColorIdentityFromIdentityAndNotFilters) +{ + for (const QChar color : QString("BRG")) { + add(QString(color), CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +TEST_F(FilterModeTest, MixedAttributesAreCustom) +{ + add("W", CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor); + add("U", CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Custom); +} + +TEST_F(FilterModeTest, CustomLeavesAsSoonAsAModeCanExplainTheFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Custom), FilterMode::Includes); +} + +// Exact match with no color selected stores one AND NOT filter per color. That is a legitimate Exact match +// state, so the live mode is kept; a mode that cannot produce the set re-derives to Excludes, which produces +// the identical filters. +TEST_F(FilterModeTest, FullyExcludedColorSetIsExactMatchOrExcludes) +{ + for (const QChar color : QString("WUBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index e561db590..931cb7e0c 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -123,6 +123,25 @@ CardInfoPtr greenIdentityCard() return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {}); } +// A card whose color identity differs from its colors, so that a query reading the wrong one is visible. +CardInfoPtr splitIdentityCard() +{ + return CardInfo::newInstance("Split Identity", "", false, {{"colors", "G"}, {"coloridentity", "W"}}, {}, {}, {}, + {}); +} + +// "ci!" (exactly these color identities) used to read the camelCase "colorIdentity" property instead of the +// lowercase "coloridentity" key, so it matched the empty string and rejected every card. +TEST_F(CardQuery, ColorIdentityExactReadsTheColorIdentityProperty) +{ + const CardData card = splitIdentityCard(); + ASSERT_TRUE(FilterString("ci!w").check(card)); + ASSERT_FALSE(FilterString("ci!g").check(card)); + // "c!" still reads colors, not identity. + ASSERT_TRUE(FilterString("c!g").check(card)); + ASSERT_FALSE(FilterString("c!w").check(card)); +} + // Whitespace belongs around the separator, never inside an operator token: "c i: g" must not // be read as the color-identity operator "ci" (it is a generic "c" AND a field query on "i"). TEST_F(CardQuery, ColorIdentityOperatorIsNotSplitByWhitespace) diff --git a/tests/carddatabase/filter_tree_test.cpp b/tests/carddatabase/filter_tree_test.cpp new file mode 100644 index 000000000..8d3706071 --- /dev/null +++ b/tests/carddatabase/filter_tree_test.cpp @@ -0,0 +1,204 @@ + +#include "libcockatrice/filters/filter_tree.h" + +#include +#include +#include + +namespace +{ +class FilterTreeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + language = CardSearchLanguage{}; + + island = CardInfo::newInstance("Island", "", false, {{"colors", ""}, {"coloridentity", "U"}}, {}, {}, {}, {}); + artifact = + CardInfo::newInstance("Artifact", "", false, {{"colors", ""}, {"coloridentity", ""}}, {}, {}, {}, {}); + truth = CardInfo::newInstance("Truth", "", false, {{"colors", "U"}, {"coloridentity", "U"}}, {}, {}, {}, {}); + notDead = + CardInfo::newInstance("Not Dead", "", false, {{"colors", "B"}, {"coloridentity", "B"}}, {}, {}, {}, {}); + wallOfDenial = CardInfo::newInstance("Wall of Denial", "", false, {{"colors", "WU"}, {"coloridentity", "WU"}}, + {}, {}, {}, {}); + maelstromAngel = CardInfo::newInstance("Maelstrom Archangel", "", false, + {{"colors", "WUBRG"}, {"coloridentity", "WUBRG"}}, {}, {}, {}, {}); + } + + CardSearchLanguage language; + + CardData island; // c/u + CardData artifact; // c + CardData truth; // u + CardData notDead; // b + CardData wallOfDenial; // wu + CardData maelstromAngel; // wubrg +}; +} // namespace + +// ===================================================================================================================== +// acceptColor +// ===================================================================================================================== + +TEST_F(FilterTreeTest, Color) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "c"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorAliases) +{ + FilterTree filterTree; + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "green"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "grn"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blue"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blu"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "black"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blk"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "red"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "white"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wht"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colorless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colourless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "none"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); +} + +// ===================================================================================================================== +// acceptColorIdentity +// ===================================================================================================================== + +TEST_F(FilterTreeTest, ColorIdentity) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "c"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityMultiple) +{ + // Simulate a WU commander. + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "r"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "g"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3339cc9d722d5d357328909453d2a56c450e366e Mon Sep 17 00:00:00 2001 From: SlightlyCircuitous <71394296+SlightlyCircuitous@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:04:50 -0500 Subject: [PATCH 245/247] Add Fedora 45 build and drop Fedora 43 build (#7774) * Create Dockerfile * Delete .ci/Fedora43 directory * Update release_template.md * Update desktop-build.yml --- .ci/{Fedora43 => Fedora45}/Dockerfile | 2 +- .ci/release_template.md | 2 +- .github/workflows/desktop-build.yml | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) rename .ci/{Fedora43 => Fedora45}/Dockerfile (96%) diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora45/Dockerfile similarity index 96% rename from .ci/Fedora43/Dockerfile rename to .ci/Fedora45/Dockerfile index 8aa345651..a7c49b249 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora45/Dockerfile @@ -1,4 +1,4 @@ -FROM fedora:43 +FROM fedora:45 RUN dnf install -y \ ca-certificates \ diff --git a/.ci/release_template.md b/.ci/release_template.md index 23b475150..8eb4c7ebd 100644 --- a/.ci/release_template.md +++ b/.ci/release_template.md @@ -21,8 +21,8 @@ Available pre-compiled binaries for installation: • Ubuntu 24.04 LTS Noble Numbat • Debian 13 Trixie • Debian 12 Bookworm + • Fedora 45 • Fedora 44 - • Fedora 43 General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)! Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository. diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 5a306e023..002255fda 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -149,7 +149,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already - debug_test: yes # see the note on Fedora 44 below + debug_test: yes # see the note on Fedora 45 below - distro: Servatrice_Debian version: 12 @@ -166,10 +166,10 @@ jobs: ccache_size: 850M # the Release build plus the suite it runs - distro: Fedora - version: 44 + version: 45 package: RPM - # Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions + # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: # only the Debug configuration passes -Werror, so these two are the legs where a # new warning in either compiler still fails the build. @@ -183,7 +183,7 @@ jobs: test: skip # The sanitizer leg below runs this distro's suite # The one leg that runs the suite under AddressSanitizer and - # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the + # UndefinedBehaviorSanitizer. Debian 13 and Fedora 45 keep covering the # uninstrumented suite, so nothing is lost by instrumenting this one. # # It packages nothing. A sanitized Debug build is not something to ship, and @@ -223,7 +223,7 @@ jobs: # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat # below what a leg writes: ccache spent each build deleting the entries the next run # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each - # leg states the size it needs instead: one configuration for most, two for Fedora 44 + # leg states the size it needs instead: one configuration for most, two for Fedora 45 # (Debug for the suite, Release for the package), the server alone for Servatrice. # Raising these further means caching fewer legs, since GitHub evicts a whole entry # when the total is exceeded. @@ -466,7 +466,7 @@ jobs: test: skip # Running tests on all distros is superfluous - distro: Fedora - version: 43 # previous release, still covered by the nightly + version: 44 # previous release, still covered by the nightly package: RPM test: skip # Running tests on all distros is superfluous @@ -1167,7 +1167,7 @@ jobs: # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. # The nightly legs keep none, so their leftover entries are reclaimed here # instead of waiting out the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora45 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" From 06e3d96308fd4c7f267b5fd2e3e9adcaf1cc14a3 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:41:53 +0100 Subject: [PATCH 246/247] [CI] Size compiler caches for one configuration and split the Fedora leg (#7772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounds in #7667 were set below what one build configuration writes, so ccache filled to its limit and evicted the entries the next build was about to ask for. The probe measured what that costs: a 6G cache on one leg served 1059 of 1059 calls and the 700M bound on the same leg served 14%. The quota also went to legs that finish under the Windows leg, which has no compiler cache. Debug information is most of an object's size. A measured Arch Debug configuration is 1.2 GB with the built-in flags and 660 MB at -g1; Debian 13 Release is 1.1 GB and 614 MB. CI therefore builds every non-tag leg at level 1 through a new COCKATRICE_DEBUG_INFO_LEVEL option, which stays empty by default so local, distro and release builds are unchanged. Level 1 keeps file and line, which is what a failing test or a sanitizer backtrace reports. Tags and the nightly legs keep full DWARF for the Sentry symbols. With one configuration at half the size, the quota goes to fewer legs at a size that holds one whole configuration, and the legs that duplicate another leg's coverage give their cache up: Servatrice, Ubuntu 26.04 behind the sanitize leg of the same distro, macOS 15 and macOS 27 Debug behind macOS 14. Their keys leave LIVE_CCACHE_KEYS, so prune-caches reclaims the existing entries. The single Fedora leg built Debug for the suite and Release for the package in one job (measured on Fedora 44: 30.8 min, the only leg above Windows), so the package compile queued behind the debug one while one 1.6 GB cache held two configurations. Fedora 45 now runs as two legs with one cache each, in parallel. The option reaches compile.sh inside the container through docker.sh, and compile.sh turns it into the CMake flag. Bounds: Arch 850M, Debian 13 800M, Fedora 45 debug 850M and release 800M, Ubuntu 26.04 sanitize 1200M, macOS 14 800M. That is 5.3 GB of compiler cache on top of roughly 4.5 GB of buildkit, vcpkg, Qt and CodeQL entries. Windows still sets the pull-request wall clock: 29.5 min, 23.7 of it compiling with no compiler cache, because ccache and sccache cannot store the 711 of 1054 translation units that use PCH under MSVC (#7381). That is a separate trial. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 5 ++ .ci/docker.sh | 5 ++ .github/workflows/desktop-build.yml | 132 +++++++++++++++++++--------- CMakeLists.txt | 21 +++++ 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 569ae9f6b..7414b2dc3 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -203,6 +203,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then # developer builds never reach Sentry. flags+=("-DWITH_CRASH_REPORTING=ON") fi +if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + # CI lowers the DWARF level so one build configuration fits its compiler-cache bound; + # see the option in CMakeLists.txt. Empty keeps the built-in flags. + flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL") +fi if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then # Release-only: Debug builds stay DSN-free so crashes from developer builds # never reach Sentry. Empty (local builds, forks without the secret, and non-tag diff --git a/.ci/docker.sh b/.ci/docker.sh index 64ea6baa7..c78ba3c31 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -181,6 +181,11 @@ function RUN () if [[ ${WITH_CRASH_REPORTING:-} ]]; then args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") fi + # Same reason: the DWARF level reaches compile.sh only if it is handed to the container, + # and without it CI would cache objects twice the size its bounds were measured for. + if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}") + fi # shellcheck disable=2086 docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" return $? diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 002255fda..b859556aa 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -150,6 +150,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already debug_test: yes # see the note on Fedora 45 below + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 - distro: Servatrice_Debian version: 12 @@ -157,30 +158,46 @@ jobs: package: DEB server_only: yes test: skip - ccache_size: 400M # server only, no client to compile + # 4.7 min and the server alone: nothing here for a compiler cache to save. + persist_ccache: 'false' - distro: Debian version: 13 package: DEB - ccache_size: 850M # the Release build plus the suite it runs + ccache_size: 800M # Release plus the suite: 614 MB of objects at -g1 + + # The single Fedora leg used to build Debug for the suite and Release for the + # package in one job (measured on Fedora 44: 30.8 min, the only leg slower than + # Windows), so the package compile waited behind the Debug one. Two legs run in + # parallel and each keeps a cache that holds exactly one configuration. + # + # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug + # because only Debug passes -Werror, so it is where a new warning still fails. + - distro: Fedora + version: 45 + variant: debug # separate image and ccache from the release leg below + + package: skip + debug_test: yes + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 - distro: Fedora version: 45 + variant: release # separate image and ccache from the debug leg above package: RPM - # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions - # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: - # only the Debug configuration passes -Werror, so these two are the legs where a - # new warning in either compiler still fails the build. - debug_test: yes - ccache_size: 900M # Debug for the suite and Release for the package + test: skip # The debug leg above runs this distro's suite + ccache_size: 800M # Release plus the package: 614 MB of objects at -g1 - distro: Ubuntu version: 26.04 package: DEB test: skip # The sanitizer leg below runs this distro's suite + # 18.1 min, and the same distro as the sanitize leg below, which runs this suite. + persist_ccache: 'false' # The one leg that runs the suite under AddressSanitizer and # UndefinedBehaviorSanitizer. Debian 13 and Fedora 45 keep covering the @@ -196,7 +213,7 @@ jobs: package: skip sanitize: yes - ccache_size: 800M # instrumented objects are larger + ccache_size: 1200M # instrumented objects are larger than the 614 MB measured; estimated name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure @@ -211,25 +228,47 @@ jobs: CCACHE_EVICTION_AGE: 7d # The bound is sized against the repository's 10 GB cache quota # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy), - # not against what one leg would like. The quota currently holds about 1.1 GB of - # CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers - # (mode=min in docker-release.yml, added here, brings the last one down to roughly - # 0.3 GB - the one input here that is an expectation rather than a measurement, since - # mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five - # nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what - # makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache - # bounds + 3.2G of the fixed entries = 9.65G against the 10G quota. + # not against what one leg would like: the quota also holds about 0.9 GB of buildkit + # layers (mode=min in docker-release.yml), 0.8 GB of vcpkg, 0.7 GB of Qt and the CodeQL + # trap caches (about 1 GB each, which prune-caches below does not manage). # - # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat - # below what a leg writes: ccache spent each build deleting the entries the next run - # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each - # leg states the size it needs instead: one configuration for most, two for Fedora 45 - # (Debug for the suite, Release for the package), the server alone for Servatrice. - # Raising these further means caching fewer legs, since GitHub evicts a whole entry - # when the total is exceeded. + # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with + # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit + # rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls; + # the 700M bound on the same leg served 14%, because ccache fills to the limit and then + # evicts the entries the next build is about to ask for. A cache that cannot hold a + # whole configuration therefore buys almost nothing, and the quota goes to fewer legs + # at a size that holds one: + # + # Arch Debug + suite 660 MB working set 850M + # Debian 13 Release + suite 614 MB 800M + # Fedora 45 debug Debug + suite 660 MB 850M + # Fedora 45 release Release + package 614 MB 800M + # Ubuntu 26.04 sanitize ~1.0 GB 1200M + # + # That is about 5.3 GB of bounds, and with the fixed entries above it puts the quota at + # roughly 9.8 GB of 10 - so only legs worth their share get one. A leg keeps no cache + # when it duplicates another leg's coverage (Ubuntu 26.04 behind the sanitize leg of + # the same distro; macOS 15 and macOS 27 Debug behind macOS 14; Servatrice covers the + # server alone and finishes in 4.7 min). Those four run cold: 4.7, 18.1, 16.5 and + # 15.8 min. The five that stay compile the client on the toolchains a change can break + # and are the ones a cache shortens by the most. Measured job totals on master + # 2026-11-04 also show the ceiling this leaves: Windows 10 is 29.5 min and compiles for + # 23.7 of it with no compiler cache at all, so Windows still sets the pull-request + # wall clock no matter how warm these legs are (see build-vcpkg). + # + # Re-add a leg only with a bound that holds its whole configuration: a smaller one is + # worse than none. # # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + # Debug information dominates a cached object: with the built-in flags one + # configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to + # 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and + # one configuration halves. Empty on tags and on the nightly legs, which ship binaries + # and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and + # is slower, which is accepted because a tag is rare and only pull requests gate. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -242,11 +281,13 @@ jobs: # also package, so testing and packaging cost one compile instead of two. DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} - # Whether this leg restores and saves its compiler cache. Every leg a pull request - # runs keeps one; the nightly-only legs do not, so that the quota is spent on the - # builds that review a change. A tag build runs both sets, and the nightly legs are - # cold there - accepted, because a tag is rare and only the pull-request legs gate. - PERSIST_CCACHE: 'true' + # Whether this leg restores and saves its compiler cache. Not every pull-request leg + # keeps one: a cache only pays for itself when its bound holds a whole configuration + # (see the sizing comment above), and a leg that duplicates another leg's coverage + # spends quota without adding any. The nightly-only legs keep none either. Fedora 45 + # states it per leg: its two configurations have their own job and key, so neither + # bound is shared with a build whose objects it would evict. + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} # Shared with build-linux-nightly so the two leg sets run the same steps. steps: &linux-steps @@ -540,6 +581,7 @@ jobs: make_package: 1 override_target: 14 package_suffix: "-macOS14" + ccache_size: 800M # one Release configuration plus the suite at -g1 qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -558,6 +600,7 @@ jobs: make_package: 1 override_target: 15 package_suffix: "-macOS15" + persist_ccache: 'false' # 16.5 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -572,6 +615,7 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja + persist_ccache: 'false' # 15.8 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -609,13 +653,17 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - # macOS configurations compile about twice the TUs of a Linux one, so each of these - # three legs gets a bound above what it has been measured to write. The four macOS - # legs used to be bounded at 600M from the same quota as the Linux ones; the three - # pull-request legs now get 700M each while the nightly Intel leg keeps none, and - # that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic). - CCACHE_SIZE: 700M - PERSIST_CCACHE: 'true' + # One macOS configuration is about 600 MB of objects at -g1, so its bound needs to be + # 800M to hold one whole; a smaller cache serves a few percent and is worthless (see + # the sizing comment in build-linux). macOS 14 keeps the one macOS cache: it runs the + # suite against the shipping Release build and is the longest of the three at 24.2 min. + # macOS 15 (16.5) and macOS 27 Debug (15.8) duplicate its coverage and are shorter, so + # they run cold. + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} + # MSVC has no -g level, so the Windows leg does not ask for one; it has no compiler + # cache to shrink anyway (see the sizing comment in build-linux). + COCKATRICE_DEBUG_INFO_LEVEL: ${{ matrix.os != 'Windows' && github.ref_type != 'tag' && '1' || '' }} # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps @@ -1165,9 +1213,10 @@ jobs: LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' # Must list the ccache key of every leg that keeps a compiler cache # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. - # The nightly legs keep none, so their leftover entries are reclaimed here - # instead of waiting out the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora45 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' + # The nightly legs and the pull-request legs that finish under the Windows leg + # keep none, so their leftover entries are reclaimed here instead of waiting out + # the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" @@ -1191,8 +1240,9 @@ jobs: if [[ $ref != refs/heads/master ]]; then reason="scoped to $ref" elif [[ $key == ccache-* ]]; then - # An exact token compare: ccache-Ubuntu26.04 is a prefix of - # ccache-Ubuntu26.04sanitize, and both keys are live + # An exact token compare, because one key can be a prefix of another: + # ccache-Ubuntu26.04 is a prefix of ccache-Ubuntu26.04sanitize, and only + # the sanitize entry is live. if [[ $live_ccache != *" $key "* ]]; then reason="no pull-request leg keeps it" fi diff --git a/CMakeLists.txt b/CMakeLists.txt index 9044bc005..53508fcd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -238,6 +238,27 @@ include(Sanitizers) include(Coverage) include(CrashReporting) +# Debug information is what makes a cached object large, and a build configuration needs more +# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to +# its limit and evicts the entries the next build is about to ask for, so it serves a few +# percent of calls while holding most of a build. CI sets a lower level so one whole +# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer +# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as +# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot +# raise the level again. +set(COCKATRICE_DEBUG_INFO_LEVEL + "" + CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)" +) +if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "") + if(MSVC) + message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC") + else() + string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + endif() +endif() + # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") add_definitions("-DSFMT_MEXP=19937") From daa8671b4d401646e6cef0337889737afabf0343 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:47:47 +0100 Subject: [PATCH 247/247] [Update] Make the update download atomic and its temp path unpredictable (#7627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The downloader wrote to a predictable /tmp/ path, ignored the result of write(), and leaked the previous reply when a check was retried. It also connected no error handler to the reply and had no transfer timeout. Create the file inside a private QTemporaryDir, write it with QSaveFile and commit it only after every byte was accepted, report reply errors, give the request a 30 s transfer timeout, and delete the superseded reply. Ignore a queued finished()/errorOccurred() from a reply that has already been replaced, and drop the manual redirect branch because Qt follows redirects by default. Co-authored-by: Lukas Brübach --- .../update/client/update_downloader.cpp | 111 ++++++++++++++---- .../network/update/client/update_downloader.h | 3 + 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index 8868876fa..8b5ef3d6c 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -1,15 +1,16 @@ #include "update_downloader.h" -#include -#include #include -#include +#include #include #include #include +#include +#include #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { @@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl) originalUrl = downloadUrl; } + if (auto *old = std::exchange(response, nullptr)) { + old->abort(); + old->deleteLater(); + } + + errorReported = false; + cancelRequested = false; + + // The transfer timeout comes from the manager (NetworkManagerFactory). response = netMan->get(QNetworkRequest(downloadUrl)); connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished); + connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError); connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress); - connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort); + connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload); } -void UpdateDownloader::downloadError(QNetworkReply::NetworkError) +void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError) { - if (response == nullptr) { + // A reply that was superseded by beginDownload() can still deliver a + // queued error; it must not be reported against the current reply. + if (response == nullptr || sender() != response || errorReported) { return; } - emit error(response->errorString().toUtf8()); + // A transfer timeout aborts the reply, so it arrives as OperationCanceledError. + // Only an abort the user asked for is suppressed; every other cancel is a + // stalled transfer and has to be reported. + if (networkError == QNetworkReply::OperationCanceledError) { + if (cancelRequested) { + return; + } + + errorReported = true; + emit error(tr("The download timed out. Check your connection and try again.")); + return; + } + + errorReported = true; + emit error(response->errorString()); +} + +void UpdateDownloader::cancelDownload() +{ + cancelRequested = true; + if (response != nullptr) { + response->abort(); + } } void UpdateDownloader::fileFinished() { - // If we finished but there's a redirect, follow it - QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute); - if (!redirect.isNull()) { - beginDownload(redirect.toUrl()); + // An aborted, superseded reply also emits finished(). Only the reply + // currently stored in response may be consumed. + if (response == nullptr || sender() != response) { return; } - // Handle any errors we had - if (response->error()) { - emit error(response->errorString()); + // errorOccurred normally reports this first; keep the check for errors that + // only surface when the reply finishes. + if (response->error() != QNetworkReply::NoError) { + downloadError(response->error()); + response->deleteLater(); + response = nullptr; return; } - // Work out the file name of the download - QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1); + // Give the update its own private directory. The name is unpredictable, so a + // local user cannot pre-create or symlink the path, and the original file + // name (including the installer suffix) is preserved inside it. + QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX"))); + if (!downloadDir.isValid()) { + emit error(tr("Could not create a temporary directory for the download.")); + response->deleteLater(); + response = nullptr; + return; + } + downloadDir.setAutoRemove(false); - // Save the build in a temporary directory - QFile file(fileName); + const QString remoteName = QFileInfo(originalUrl.path()).fileName(); + const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName); + + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { - emit error(tr("Could not open the file for reading.")); + downloadDir.remove(); + emit error(tr("Could not open the file for writing: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; return; } - file.write(response->readAll()); - file.close(); + const QByteArray data = response->readAll(); + if (file.write(data) != data.size()) { + file.cancelWriting(); + downloadDir.remove(); + emit error(tr("Could not write the downloaded file to disk.")); + response->deleteLater(); + response = nullptr; + return; + } + + if (!file.commit()) { + downloadDir.remove(); + emit error(tr("Could not save the downloaded file: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; + return; + } + + response->deleteLater(); + response = nullptr; // Emit the success signal with a URL to the download file emit downloadSuccessful(QUrl::fromLocalFile(fileName)); diff --git a/cockatrice/src/client/network/update/client/update_downloader.h b/cockatrice/src/client/network/update/client/update_downloader.h index 8aeefb22a..7704eca38 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.h +++ b/cockatrice/src/client/network/update/client/update_downloader.h @@ -32,10 +32,13 @@ private: QUrl originalUrl; QNetworkAccessManager *netMan; QNetworkReply *response; + bool errorReported = false; + bool cancelRequested = false; private slots: void fileFinished(); void downloadProgress(qint64 bytesRead, qint64 totalBytes); void downloadError(QNetworkReply::NetworkError); + void cancelDownload(); }; #endif // COCKATRICE_UPDATEDOWNLOADER_H