Merge upstream/master into fix/picture-loader-cache-offline

Keep the decoder-aware Accept header from this branch; upstream #6609 only
fixed the wildcard spelling of the previous hardcoded header.
This commit is contained in:
Lukas Brübach 2026-11-08 16:48:52 +01:00
commit 00ce5e9ce3
207 changed files with 5681 additions and 1240 deletions

View file

@ -1,9 +1,9 @@
from archlinux:latest FROM archlinux:latest
RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
base-devel \
ccache \ ccache \
cmake \ cmake \
gcc \
git \ git \
gtest \ gtest \
mariadb-libs \ mariadb-libs \

View file

@ -1,16 +1,17 @@
FROM debian:12 FROM debian:12-slim
RUN apt-get update && \ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \ ca-certificates \
ccache \ ccache \
cmake \ cmake \
dpkg-dev \
file \ file \
g++ \ g++ \
git \ git \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
libgl-dev \ libgl-dev \
libgtest-dev \
liblzma-dev \ liblzma-dev \
libmariadb-dev-compat \ libmariadb-dev-compat \
libprotobuf-dev \ libprotobuf-dev \

View file

@ -1,16 +1,17 @@
FROM debian:13 FROM debian:13-slim
RUN apt-get update && \ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \ ca-certificates \
ccache \ ccache \
cmake \ cmake \
dpkg-dev \
file \ file \
g++ \ g++ \
git \ git \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
libgl-dev \ libgl-dev \
libgtest-dev \
liblzma-dev \ liblzma-dev \
libmariadb-dev-compat \ libmariadb-dev-compat \
libprotobuf-dev \ libprotobuf-dev \

View file

@ -1,11 +1,12 @@
FROM fedora:44 FROM fedora:44
RUN dnf install -y \ RUN dnf install -y --nodocs \
ca-certificates \ ca-certificates \
ccache \ ccache \
cmake \ cmake \
gcc-c++ \ gcc-c++ \
git \ git \
gtest-devel \
libcurl-devel \ libcurl-devel \
mariadb-devel \ mariadb-devel \
ninja-build \ ninja-build \

View file

@ -1,11 +1,12 @@
FROM fedora:43 FROM fedora:45
RUN dnf install -y \ RUN dnf install -y --nodocs \
ca-certificates \ ca-certificates \
ccache \ ccache \
cmake \ cmake \
gcc-c++ \ gcc-c++ \
git \ git \
gtest-devel \
libcurl-devel \ libcurl-devel \
mariadb-devel \ mariadb-devel \
ninja-build \ ninja-build \

View file

@ -1,10 +1,10 @@
FROM debian:12 FROM debian:12-slim
RUN apt-get update && \ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ccache \ ccache \
cmake \ cmake \
dpkg-dev \
file \ file \
g++ \ g++ \
git \ git \

View file

@ -2,15 +2,16 @@ FROM ubuntu:24.04
RUN apt-get update && \ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \ ca-certificates \
ccache \ ccache \
cmake \ cmake \
dpkg-dev \
file \ file \
g++ \ g++ \
git \ git \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
libgl-dev \ libgl-dev \
libgtest-dev \
liblzma-dev \ liblzma-dev \
libmariadb-dev-compat \ libmariadb-dev-compat \
libprotobuf-dev \ libprotobuf-dev \

View file

@ -2,16 +2,17 @@ FROM ubuntu:26.04
RUN apt-get update && \ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \ ca-certificates \
ccache \ ccache \
cmake \ cmake \
dpkg-dev \
file \ file \
g++ \ g++ \
gcovr \ gcovr \
git \ git \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
libgl-dev \ libgl-dev \
libgtest-dev \
liblzma-dev \ liblzma-dev \
libmariadb-dev-compat \ libmariadb-dev-compat \
libprotobuf-dev \ libprotobuf-dev \

View file

@ -20,6 +20,7 @@
# --profile writes an MSBuild performance summary and binary log, Windows only # --profile writes an MSBuild performance summary and binary log, Windows only
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE # 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 <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile) # (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile)
# boolean env flags are enabled with the value 1 (e.g. MAKE_SERVER=1)
# exitcode: 1 for failure, 3 for invalid arguments # exitcode: 1 for failure, 3 for invalid arguments
# Read arguments # Read arguments
@ -138,17 +139,21 @@ done
set -e set -e
# Setup # Schema version consistency (only relevant to the server)
./servatrice/check_schema_version.sh if [[ ${MAKE_SERVER:-} == "1" ]]; then
if [[ ! $BUILDTYPE ]]; then ./servatrice/check_schema_version.sh
fi
# Use default values if unset
if [[ -z $BUILDTYPE ]]; then
BUILDTYPE=Release BUILDTYPE=Release
fi fi
if [[ ! $BUILD_DIR ]]; then if [[ -z $BUILD_DIR ]]; then
BUILD_DIR="build" BUILD_DIR="build"
fi fi
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
if [[ $MAKE_COVERAGE ]]; then if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
# Resolved while the working directory is still the source root. $0 is relative to the # 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 # 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 # under `set -e` the failed subshell would abort the build rather than just yielding an empty
@ -163,16 +168,16 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10
# Add cmake flags # Add cmake flags
flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE") flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE")
if [[ $MAKE_SERVER ]]; then if [[ ${MAKE_SERVER:-} == "1" ]]; then
flags+=("-DWITH_SERVER=1") flags+=("-DWITH_SERVER=1")
fi fi
if [[ $MAKE_NO_CLIENT ]]; then if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then
flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0") flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0")
fi fi
if [[ $MAKE_TEST ]]; then if [[ ${MAKE_TEST:-} == "1" ]]; then
flags+=("-DTEST=1") flags+=("-DTEST=1")
fi fi
if [[ $MAKE_SANITIZE ]]; then if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
flags+=("-DENABLE_SANITIZERS=ON") flags+=("-DENABLE_SANITIZERS=ON")
else else
# Every leg states the flag explicitly rather than relying on the CMake # Every leg states the flag explicitly rather than relying on the CMake
@ -180,20 +185,20 @@ else
# never leak libasan/libubsan into a shipped binary. # never leak libasan/libubsan into a shipped binary.
flags+=("-DENABLE_SANITIZERS=OFF") flags+=("-DENABLE_SANITIZERS=OFF")
fi fi
if [[ $MAKE_COVERAGE ]]; then if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
flags+=("-DENABLE_COVERAGE=ON") flags+=("-DENABLE_COVERAGE=ON")
fi fi
if [[ $USE_CCACHE ]]; then if [[ ${USE_CCACHE:-} == "1" ]]; then
flags+=("-DUSE_CCACHE=1") flags+=("-DUSE_CCACHE=1")
if [[ $CCACHE_SIZE ]]; then if [[ -n $CCACHE_SIZE ]]; then
# note, this setting persists after running the script # This setting persists after running the script
ccache --max-size "$CCACHE_SIZE" ccache --max-size "$CCACHE_SIZE"
fi fi
fi fi
if [[ $PACKAGE_TYPE ]]; then if [[ -n $PACKAGE_TYPE ]]; then
flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE") flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE")
fi fi
if [[ $USE_VCPKG ]]; then if [[ ${USE_VCPKG:-} == "1" ]]; then
flags+=("-DUSE_VCPKG=1") flags+=("-DUSE_VCPKG=1")
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
fi fi
@ -203,6 +208,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then
# developer builds never reach Sentry. # developer builds never reach Sentry.
flags+=("-DWITH_CRASH_REPORTING=ON") flags+=("-DWITH_CRASH_REPORTING=ON")
fi 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 if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then
# Release-only: Debug builds stay DSN-free so crashes from developer builds # 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 # never reach Sentry. Empty (local builds, forks without the secret, and non-tag
@ -216,7 +226,8 @@ fi
buildflags=(--config "$BUILDTYPE") buildflags=(--config "$BUILDTYPE")
function ccachestatsverbose() { function ccachestatsverbose() {
# note, verbose only works on newer ccache, discard the error ccache --version
# Verbose only works on newer ccache, discard the error
local got local got
if got="$(ccache --show-stats --verbose 2>/dev/null)"; then if got="$(ccache --show-stats --verbose 2>/dev/null)"; then
echo "$got" echo "$got"
@ -256,42 +267,44 @@ if [[ $RUNNER_OS == macOS ]]; then
# this works independent of the qt version as there should be only one version installed on the runner at a time # this works independent of the qt version as there should be only one version installed on the runner at a time
export QTDIR export QTDIR
if [[ $TARGET_MACOS_VERSION ]]; then if [[ -n $TARGET_MACOS_VERSION ]]; then
# CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla CMake flag needed to compile to the target macOS version
flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION") flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION")
# vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version if [[ ${USE_VCPKG:-} == "1" ]]; then
# an easy way is to copy the x64-osx.cmake file and modify it # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
triplets_dir="/tmp/cmake/triplets" # An easy way is to copy the x64-osx.cmake file and modify it
triplet_version="custom-triplet" triplets_dir="/tmp/cmake/triplets"
triplet_file="$triplets_dir/$triplet_version.cmake" triplet_version="custom-triplet"
arch=$(uname -m) triplet_file="$triplets_dir/$triplet_version.cmake"
if [[ $arch == x86_64 ]]; then arch=$(uname -m)
arch="x64" if [[ $arch == x86_64 ]]; then
arch="x64"
fi
mkdir -p "$triplets_dir"
triplet_source="../vcpkg/triplets/$arch-osx.cmake"
if [[ ! -f "$triplet_source" ]]; then
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
fi
cp "$triplet_source" "$triplet_file"
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
# produce identical binaries when they share the lowest deployment target, so they all
# build against 14 to keep one vcpkg cache scope instead of three.
# Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
echo "::group::Generated triplet $triplet_file"
cat "$triplet_file"
echo "::endgroup::"
fi fi
mkdir -p "$triplets_dir"
triplet_source="../vcpkg/triplets/$arch-osx.cmake"
if [[ ! -f "$triplet_source" ]]; then
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
fi
cp "$triplet_source" "$triplet_file"
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
# produce identical binaries when they share the lowest deployment target, so they all
# build against 14 to keep one vcpkg cache scope instead of three.
# Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
echo "::group::Generated triplet $triplet_file"
cat "$triplet_file"
echo "::endgroup::"
fi fi
echo "::group::Signing Certificate" echo "::group::Setup signing certificate"
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then
echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12" echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12"
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
@ -306,9 +319,13 @@ if [[ $RUNNER_OS == macOS ]]; then
fi fi
echo "::endgroup::" echo "::endgroup::"
if [[ $MAKE_PACKAGE ]]; then if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522 # Workaround https://github.com/actions/runner-images/issues/7522: XProtect holds a
# have hdiutil repeat the command 10 times in hope of success. # freshly written DMG for a moment ("Resource busy"), and a mount that outlived a
# failed detach stays busy for the rest of the job. Retry every hdiutil call through
# a wrapper that widens the delay so the scan can finish, retries a detach with
# -force, and kills a wedged DiskImages helper instead of letting it hold the job
# until the 100 minute timeout.
# The wrapper has to exit non-zero once it gives up: CPack reads that status to # 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 # 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 # into a much later, misleading "Problem copying the package" error that hides
@ -317,17 +334,74 @@ if [[ $RUNNER_OS == macOS ]]; then
# shellcheck disable=SC2016 # shellcheck disable=SC2016
echo '#!/bin/bash echo '#!/bin/bash
attempts=10 attempts=10
i=0 timeout_seconds=${HDIUTIL_TIMEOUT:-300}
while true; do subcommand=$1
hdiutil "$@" && exit 0 # CPACK_DMG_VOLUME_NAME in CMakeLists.txt; a -volname argument wins when there is one.
rc=$? volume_name=Cockatrice
if (( ++i >= attempts )); then arg_count=$#
echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2 for (( i = 1; i < arg_count; i++ )); do
exit "$rc" if [[ ${!i} == -volname ]] && (( i + 1 <= arg_count )); then
(( i++ ))
volume_name=${!i}
fi fi
echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2 done
sleep 1
done' >"$hdiutil_script" # macOS ships no timeout(1), so a watchdog kills the supervised call. It writes to
# /dev/null because a process holding the output pipe open would make CPack wait for
# the watchdog instead of for hdiutil; it is killed once the call returns.
run_supervised() {
"$@" &
pid=$!
( sleep "$timeout_seconds" && kill -9 "$pid" ) >/dev/null 2>&1 &
watchdog=$!
wait "$pid"
rc=$?
kill "$watchdog" 2>/dev/null
wait "$watchdog" 2>/dev/null
return "$rc"
}
force_detach_stale_mount() {
[[ -d /Volumes/$volume_name ]] || return 0
echo "hdiutil $subcommand: force-detaching stale /Volumes/$volume_name" >&2
run_supervised hdiutil detach -force "/Volumes/$volume_name" >/dev/null 2>&1
return 0
}
timeouts=0
attempt=0
delay=1
while (( attempt < attempts )); do
(( attempt++ ))
cmd=(hdiutil "$@")
# A busy volume is what a detach fails on, and -force is what clears it.
if (( attempt > 1 )) && [[ $subcommand == detach ]]; then
cmd=(hdiutil detach -force "${@:2}")
fi
run_supervised "${cmd[@]}"
rc=$?
(( rc == 0 )) && exit 0
if (( rc == 137 )); then
(( timeouts++ ))
echo "hdiutil $subcommand attempt $attempt/$attempts timed out after ${timeout_seconds}s" >&2
# A wedged DiskImages helper is not the busy-file flake: two kills are all the
# evidence the loop needs before failing is a better use of the job budget.
if (( timeouts >= 2 )); then
echo "::error::hdiutil $subcommand timed out $timeouts times, giving up" >&2
exit "$rc"
fi
else
echo "hdiutil $subcommand attempt $attempt/$attempts failed (status $rc), retrying" >&2
fi
(( attempt < attempts )) || break
if [[ $subcommand == create || $subcommand == attach ]]; then
force_detach_stale_mount
fi
sleep "$delay"
(( delay = delay * 2 > 30 ? 30 : delay * 2 ))
done
echo "::error::hdiutil $subcommand failed $attempt times (last status $rc), giving up" >&2
exit "$rc"' >"$hdiutil_script"
chmod +x "$hdiutil_script" chmod +x "$hdiutil_script"
flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script") flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script")
fi fi
@ -337,7 +411,7 @@ elif [[ $RUNNER_OS == Windows ]]; then
# cmake only hands MSBuild a job count when one is asked for, and without it # 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 # MSBuild builds a single node: one project at a time, whatever MTT does inside
# it. Ask for a node per core. # it. Ask for a node per core.
if [[ ! $BUILD_PARALLEL_LEVEL ]]; then if [[ -z $BUILD_PARALLEL_LEVEL ]]; then
BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}" BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
fi fi
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL") buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
@ -363,19 +437,20 @@ elif [[ $RUNNER_OS == Windows ]]; then
fi fi
fi fi
if [[ $USE_CCACHE ]]; then if [[ ${USE_CCACHE:-} == "1" ]]; then
echo "::group::Show ccache stats" echo "::group::Show ccache stats"
ccachestatsverbose ccachestatsverbose
echo "::endgroup::" echo "::endgroup::"
fi fi
echo "::group::Configure cmake" # Configure CMake
echo "::group::Generate build system"
cmake --version cmake --version
echo "Running cmake with flags: ${flags[*]}" echo "Running CMake configuration with the following flags: ${flags[*]}"
cmake .. "${flags[@]}" cmake .. "${flags[@]}"
echo "::endgroup::" echo "::endgroup::"
if [[ $USE_CCACHE ]]; then if [[ ${USE_CCACHE:-} == "1" ]]; then
# PCH-aware caching is required or ccache refuses to cache any TU that consumes a # 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 # 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 # entries cover files created during the build itself: the moc/uic/rcc output and the
@ -390,9 +465,10 @@ if [[ $USE_CCACHE ]]; then
ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime
fi fi
# Build
echo "::group::Build project" echo "::group::Build project"
cpuinfo cpuinfo
echo "Running cmake --build with flags: ${buildflags[*]}" echo "Running CMake with the following build flags: ${buildflags[*]}"
cmake --build . "${buildflags[@]}" cmake --build . "${buildflags[@]}"
echo "::endgroup::" echo "::endgroup::"
@ -404,16 +480,16 @@ if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
echo "::endgroup::" echo "::endgroup::"
fi fi
if [[ $USE_CCACHE ]]; then if [[ ${USE_CCACHE:-} == "1" ]]; then
if [[ $CCACHE_EVICTION_AGE ]]; then if [[ -n $CCACHE_EVICTION_AGE ]]; then
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE" echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE"
ccache --evict-older-than "$CCACHE_EVICTION_AGE" ccache --evict-older-than "$CCACHE_EVICTION_AGE"
echo "::endgroup::" echo "::endgroup::"
fi fi
echo "::group::Show ccache stats again" echo "::group::Show ccache stats again"
ccachestatsverbose ccachestatsverbose
echo "::endgroup::" echo "::endgroup::"
elif [[ $CCACHE_EVICTION_AGE ]]; then elif [[ -n $CCACHE_EVICTION_AGE ]]; then
echo "::error file=$0::ccache eviction is enabled while ccache is disabled!" echo "::error file=$0::ccache eviction is enabled while ccache is disabled!"
fi fi
@ -430,9 +506,9 @@ if [[ $RUNNER_OS == macOS ]]; then
echo "::endgroup::" echo "::endgroup::"
fi fi
if [[ $MAKE_TEST ]]; then if [[ ${MAKE_TEST:-} == "1" ]]; then
echo "::group::Run tests" echo "::group::Run tests"
if [[ $MAKE_SANITIZE ]]; then if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
# detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and # 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 # 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 # of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for
@ -450,11 +526,12 @@ if [[ $MAKE_TEST ]]; then
# bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to # 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, # 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. # which already scopes its local socket name to its own pid so parallel copies cannot collide.
ctest --version
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}" ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
echo "::endgroup::" echo "::endgroup::"
fi fi
if [[ $MAKE_COVERAGE ]]; then if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
# gcovr is not a build dependency and is not available everywhere, so a missing one is reported # 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. # and skipped rather than failing an otherwise green build.
echo "::group::Coverage report" echo "::group::Coverage report"
@ -479,7 +556,7 @@ if [[ $MAKE_COVERAGE ]]; then
echo "::endgroup::" echo "::endgroup::"
fi fi
if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then if [[ $RUNNER_OS == Windows && ${MAKE_NO_CLIENT:-} != "1" ]]; then
echo "::group::Probe the installer's holder lookup" echo "::group::Probe the installer's holder lookup"
# The working directory is still the build dir here: the package steps below are what # 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 # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name
@ -679,18 +756,21 @@ POWERSHELL
echo "::endgroup::" echo "::endgroup::"
fi fi
if [[ $MAKE_INSTALL ]]; then # Install
if [[ ${MAKE_INSTALL:-} == "1" ]]; then
echo "::group::Install" echo "::group::Install"
cmake --build . --target install --config "$BUILDTYPE" cmake --build . --target install --config "$BUILDTYPE"
echo "::endgroup::" echo "::endgroup::"
fi fi
if [[ $MAKE_PACKAGE ]]; then # Package
if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
echo "::group::Create package" echo "::group::Create package"
cpack --version
cmake --build . --target package --config "$BUILDTYPE" cmake --build . --target package --config "$BUILDTYPE"
echo "::endgroup::" echo "::endgroup::"
if [[ $PACKAGE_SUFFIX ]]; then if [[ -n $PACKAGE_SUFFIX ]]; then
echo "::group::Update package name" echo "::group::Update package name"
cd .. cd ..
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"

View file

@ -181,6 +181,11 @@ function RUN ()
if [[ ${WITH_CRASH_REPORTING:-} ]]; then if [[ ${WITH_CRASH_REPORTING:-} ]]; then
args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
fi 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 # shellcheck disable=2086
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
return $? return $?

View file

@ -21,8 +21,8 @@ Available pre-compiled binaries for installation:
• <kbd>Ubuntu 24.04 LTS</kbd> <sub><i>Noble Numbat</i></sub> • <kbd>Ubuntu 24.04 LTS</kbd> <sub><i>Noble Numbat</i></sub>
• <kbd>Debian 13</kbd> <sub><i>Trixie</i></sub> • <kbd>Debian 13</kbd> <sub><i>Trixie</i></sub>
• <kbd>Debian 12</kbd> <sub><i>Bookworm</i></sub> • <kbd>Debian 12</kbd> <sub><i>Bookworm</i></sub>
• <kbd>Fedora 45</kbd>
• <kbd>Fedora 44</kbd> • <kbd>Fedora 44</kbd>
• <kbd>Fedora 43</kbd>
<sub>General Linux support is available via a <kbd>flatpak</kbd> package hosted at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a> (x86 & ARM)!</sub> <sub>General Linux support is available via a <kbd>flatpak</kbd> package hosted at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a> (x86 & ARM)!</sub>
<sub>Thanks to courtesy of @FFY00, the app is also available in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>.</sub> <sub>Thanks to courtesy of @FFY00, the app is also available in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>.</sub>

View file

@ -2,6 +2,6 @@ build/
.github/ .github/
.tx/ .tx/
cockatrice/ cockatrice/
doc/ docs/
oracle/ oracle/
Dockerfile Dockerfile

View file

@ -15,8 +15,11 @@ updates:
# Check for updates once a month # Check for updates once a month
schedule: schedule:
interval: "monthly" interval: "monthly"
# A new version is not considered for a version update until x days after its release (default = 3)
cooldown:
default-days: 3
# Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted)
open-pull-requests-limit: 2 open-pull-requests-limit: 3
# # Enable version updates for Docker # # Enable version updates for Docker
# Not yet possible to bump from one LTS version to the next and skip others, see: # Not yet possible to bump from one LTS version to the next and skip others, see:
@ -27,6 +30,9 @@ updates:
# # Check for updates once a week # # Check for updates once a week
# schedule: # schedule:
# interval: "weekly" # interval: "weekly"
# # A new version is not considered for a version update until x days after its release (default = 3)
# cooldown:
# default-days: 3
# # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) # # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted)
# open-pull-requests-limit: 1 # open-pull-requests-limit: 1
@ -37,5 +43,8 @@ updates:
# Check for updates to GitHub Actions once a week # Check for updates to GitHub Actions once a week
schedule: schedule:
interval: "weekly" interval: "weekly"
# A new version is not considered for a version update until x days after its release (default = 3)
cooldown:
default-days: 3
# Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted)
open-pull-requests-limit: 2 open-pull-requests-limit: 5

View file

@ -40,10 +40,10 @@ jobs:
steps: steps:
- name: "Checkout repository" - name: "Checkout repository"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Initialize CodeQL" - name: "Initialize CodeQL"
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@ -92,6 +92,6 @@ jobs:
run: cmake --build build run: cmake --build build
- name: "Perform CodeQL Analysis" - name: "Perform CodeQL Analysis"
uses: github/codeql-action/analyze@v4 uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with: with:
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"

View file

@ -15,7 +15,7 @@ on:
- '!**.md' - '!**.md'
- '!.github/**' - '!.github/**'
- '!.tx/**' - '!.tx/**'
- '!doc/**' - '!docs/**'
- '.github/workflows/desktop-build.yml' - '.github/workflows/desktop-build.yml'
- 'CMakeLists.txt' - 'CMakeLists.txt'
- 'vcpkg.json' - 'vcpkg.json'
@ -28,7 +28,7 @@ on:
- '!**.md' - '!**.md'
- '!.github/**' - '!.github/**'
- '!.tx/**' - '!.tx/**'
- '!doc/**' - '!docs/**'
- '.github/workflows/desktop-build.yml' - '.github/workflows/desktop-build.yml'
- 'CMakeLists.txt' - 'CMakeLists.txt'
- 'vcpkg.json' - 'vcpkg.json'
@ -47,6 +47,20 @@ concurrency:
group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})" group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
cancel-in-progress: ${{ github.ref_type != 'tag' }} cancel-in-progress: ${{ github.ref_type != 'tag' }}
# Qt's online installer ships its base package as separate archives. Installing only the
# ones Cockatrice loads drops the full 25+ archive download to this handful (Windows Qt
# cache: 440 MB → 180 MB, macOS thin Qt: ~200 MB → 60 MB). The addons Cockatrice also needs
# (Multimedia, WebSockets, ShaderTools, ImageFormats) are not base archives: they stay in
# each leg's qt_modules.
#
# The list mirrors what cmake/FindQtRuntime.cmake requires, so a component that is missing
# here fails configure with "No suitable version of Qt was found" instead of at runtime:
# qtbase supplies Core, Gui, Widgets, Network, Sql, Xml, Concurrent and PrintSupport;
# qtdeclarative supplies Qml, Quick and QuickWidgets; qtsvg supplies Svg; qttools supplies
# Linguist (lrelease/lupdate); qttranslations ships Qt's own catalogs.
env:
QT_ARCHIVES: qtbase qtdeclarative qtsvg qttools qttranslations
jobs: jobs:
configure: configure:
name: Configure name: Configure
@ -77,7 +91,7 @@ jobs:
- name: "Checkout" - name: "Checkout"
if: steps.configure.outputs.tag != null if: steps.configure.outputs.tag != null
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
fetch-depth: 0 # fetch all history for all branches and tags fetch-depth: 0 # fetch all history for all branches and tags
@ -89,7 +103,7 @@ jobs:
TAG: ${{ steps.configure.outputs.tag }} TAG: ${{ steps.configure.outputs.tag }}
run: .ci/prep_release.sh run: .ci/prep_release.sh
- name: "Create release" - name: "Create or update draft release"
if: steps.configure.outputs.tag != null if: steps.configure.outputs.tag != null
id: create_release id: create_release
shell: bash shell: bash
@ -101,20 +115,42 @@ jobs:
body_path: ${{ steps.prepare.outputs.body_path }} body_path: ${{ steps.prepare.outputs.body_path }}
prerelease: ${{ steps.prepare.outputs.is_beta }} prerelease: ${{ steps.prepare.outputs.is_beta }}
run: | run: |
args=() # A release for this tag may already exist: a maintainer can save a draft in
[[ $prerelease == yes ]] && args+=(--prerelease) # the GitHub UI before pushing the tag, or an earlier run may have left one.
# Never touch a published release, and never overwrite a draft that already
# has a body (generated notes or a maintainer's edits) - only fill in an
# empty draft. Prints "<isDraft> <body length>"; empty when there is none.
release_state="$(gh release view "$tag_name" --json isDraft,body \
--template '{{.isDraft}} {{len (or .body "")}}' 2>/dev/null || true)"
gh release create "$tag_name" --verify-tag --draft "${args[@]}" \ if [[ -z "$release_state" ]]; then
--target "$target" \ args=()
--title "$release_name" \ [[ $prerelease == yes ]] && args+=(--prerelease)
--notes-file "$body_path"
gh release create "$tag_name" --verify-tag --draft "${args[@]}" \
--target "$target" \
--title "$release_name" \
--notes-file "$body_path"
elif [[ "$release_state" == "true 0" ]]; then
echo "Filling in the empty draft release $tag_name"
args=(--prerelease=false)
[[ $prerelease == yes ]] && args=(--prerelease)
gh release edit "$tag_name" "${args[@]}" \
--title "$release_name" \
--notes-file "$body_path"
elif [[ "$release_state" == true* ]]; then
echo "::warning::Draft release $tag_name already has a body, leaving it untouched"
else
echo "::warning::Release $tag_name is already published, leaving it untouched"
fi
vcpkg-baseline: vcpkg-baseline:
name: vcpkg baseline name: vcpkg baseline
runs-on: ubuntu-slim runs-on: ubuntu-slim
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# vcpkg-tool reads versions/baseline.json out of the commit named by # vcpkg-tool reads versions/baseline.json out of the commit named by
# `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
@ -149,7 +185,8 @@ jobs:
allow-failure: yes allow-failure: yes
package: skip # We are packaged in Arch already package: skip # We are packaged in Arch already
debug_test: yes # see the note on Fedora 44 below debug_test: yes # see the note on Fedora 45 below
ccache_size: 850M # Debug plus the suite: 680 MB working set at -g1
- distro: Servatrice_Debian - distro: Servatrice_Debian
version: 12 version: 12
@ -157,46 +194,47 @@ jobs:
package: DEB package: DEB
server_only: yes server_only: yes
test: skip 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 - distro: Debian
version: 13 version: 13
package: DEB package: DEB
ccache_size: 850M # the Release build plus the suite it runs ccache_size: 800M # Release plus the suite: 625 MB working set at -g1
# The single Fedora leg used to build Debug for the suite and Release for the
# package in one job (measured on Fedora 44: 30.8 min, the only leg slower than
# Windows), so the package compile waited behind the Debug one. Two legs run in
# parallel and each keeps a cache that holds exactly one configuration.
#
# Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions
# in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug
# because only Debug passes -Werror, so it is where a new warning still fails.
- distro: Fedora
version: 45
variant: debug # separate image and ccache from the release leg below
package: skip
debug_test: yes
ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1
- distro: Fedora - distro: Fedora
version: 44 version: 45
variant: release # separate image and ccache from the debug leg above
package: RPM package: RPM
# Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions test: skip # The debug leg above runs this distro's suite
# in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: ccache_size: 800M # Release plus the package: 614 MB of objects at -g1
# 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 - distro: Ubuntu
version: 26.04 version: 26.04
package: DEB package: DEB
test: skip # The sanitizer leg below runs this distro's suite test: skip # Debian 13 covers the uninstrumented suite on pull requests
# 18.1 min, shorter than the Windows leg. The sanitize leg in the nightly
# The one leg that runs the suite under AddressSanitizer and # matrix runs this distro's instrumented suite.
# UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the persist_ccache: 'false'
# uninstrumented suite, so nothing is lost by instrumenting this one.
#
# It packages nothing. A sanitized Debug build is not something to ship, and
# carrying the package build here meant 18 minutes of Release compile queued
# behind the instrumented one in a single job. The leg above publishes this
# distro's DEB.
- distro: Ubuntu
version: 26.04
variant: sanitize # separate image and ccache, same distro
package: skip
sanitize: yes
ccache_size: 800M # instrumented objects are larger
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure needs: configure
@ -211,25 +249,71 @@ jobs:
CCACHE_EVICTION_AGE: 7d CCACHE_EVICTION_AGE: 7d
# The bound is sized against the repository's 10 GB cache quota # 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), # (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 # not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB
# CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers # of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit
# (mode=min in docker-release.yml, added here, brings the last one down to roughly # layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry
# 0.3 GB - the one input here that is an expectation rather than a measurement, since # per analyzed commit, about 1.07 GB each, of which every one but the newest is dead.
# mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five # That is 10.30 GB against the 10 GB quota, so every save past this point costs some
# nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what # other entry, and least-recently-used order reaches the buildkit layers before the
# makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache # trap caches. prune-caches below keeps only the newest trap cache; the live total is
# bounds + 3.2G of the fixed entries = 9.65G against the 10G quota. # then 7.1 GB, which is what leaves room for the Windows and macOS compiler caches.
# #
# A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with
# below what a leg writes: ccache spent each build deleting the entries the next run # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit
# asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each # rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls;
# leg states the size it needs instead: one configuration for most, two for Fedora 44 # the 700M bound on the same leg served 14%, because ccache fills to the limit and then
# (Debug for the suite, Release for the package), the server alone for Servatrice. # evicts the entries the next build is about to ask for. A cache that cannot hold a
# Raising these further means caching fewer legs, since GitHub evicts a whole entry # whole configuration therefore buys almost nothing, and the quota goes to fewer legs
# when the total is exceeded. # at a size that holds one:
#
# Arch Debug + suite 680 MB working set 850M
# Debian 13 Release + suite 625 MB 800M
# Fedora 45 debug Debug + suite 660 MB 850M
# Fedora 45 release Release + package 614 MB 800M
# Windows 10 Release + package 890 MB working set 950M
# macOS 14/15 Release + package 0.5 GB ccache at -g1 700M
# macOS 27 Debug Debug + suite 0.5 GB ccache at -g1 700M
#
# That is 6.35 GB of bounds; what lands in the quota is 5.15 GB of compiler cache,
# 2.97 GB stored by the Linux legs, 0.95 GB by Windows and 1.23 GB by the three macOS
# legs, for 9.3 GB of 10 in total.
# So only legs worth their share get one. A leg keeps no cache when it duplicates
# another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite) or finishes
# well inside the Windows leg (Servatrice covers the server alone in 4.7 min). The
# macOS legs compile the client with a different toolchain (Apple clang), and one
# configuration writes only ~0.5 GB, so all three fit beside the Windows entry even
# though a macOS cache served 42% and saved about 32s on a 20 min leg. The four Linux
# legs that stay compile the client on the toolchains a change can break and are the
# ones a cache shortens by the most.
# Measured job totals on master 2026-11-04 also show the ceiling this leaves:
# Windows 10 is 29.5 min and compiles for 23.7 of it, so Windows sets the
# pull-request wall clock no matter how warm these legs are. That is why the Windows
# leg keeps the largest bound (see build-vcpkg): MSVC has no -g level to shrink, and
# with /Z7 the whole configuration is cacheable, PCH included.
#
# Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13
# 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release
# 99.2%. The 75%/82% seen the day before were warming runs - the -g1 switch had
# invalidated the cache one commit earlier - not thrashing at the bound. A cold 3G
# run on the same day measured one configuration at 680 MB (Arch) and 625 MB
# (Debian 13), so those bounds already hold the working set with ~170 MB of slack for
# commit-to-commit churn, and raising them buys hit rate the numbers do not show a
# need for. The three macOS entries are 700M each because one configuration wrote a
# 0.5 GB ccache directory at -g1 and level 0 shrinks it further, so the bound holds the
# whole configuration with room to spare.
#
# Re-add a leg only with a bound that holds its whole configuration: a smaller one is
# worse than none.
# #
# Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix.
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} 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' CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }} NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }} IMAGE_SUFFIX: ${{ matrix.variant }}
@ -242,21 +326,23 @@ jobs:
# also package, so testing and packaging cost one compile instead of two. # also package, so testing and packaging cost one compile instead of two.
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} 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') }} 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 # Whether this leg restores and saves its compiler cache. Not every pull-request leg
# runs keeps one; the nightly-only legs do not, so that the quota is spent on the # keeps one: a cache only pays for itself when its bound holds a whole configuration
# builds that review a change. A tag build runs both sets, and the nightly legs are # (see the sizing comment above), and a leg that duplicates another leg's coverage
# cold there - accepted, because a tag is rare and only the pull-request legs gate. # spends quota without adding any. The nightly-only legs keep none either. Fedora 45
PERSIST_CCACHE: 'true' # 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. # Shared with build-linux-nightly so the two leg sets run the same steps.
steps: &linux-steps steps: &linux-steps
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Restore compiler cache (ccache)" - name: "Restore compiler cache (ccache)"
id: restore_ccache id: restore_ccache
if: env.PERSIST_CCACHE == 'true' if: env.PERSIST_CCACHE == 'true'
uses: actions/cache/restore@v6 uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
path: ${{ env.CACHE }}/.ccache path: ${{ env.CACHE }}/.ccache
@ -303,7 +389,7 @@ jobs:
- name: "Upload coverage artifact" - name: "Upload coverage artifact"
if: matrix.coverage == 'yes' if: matrix.coverage == 'yes'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: coverage-report name: coverage-report
path: coverage/ path: coverage/
@ -399,6 +485,14 @@ jobs:
# comes from the client event itself (COCKATRICE_SENTRY_RELEASE). # comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
# Pull requests and tags only restore; only a push to refs/heads/master writes. A PR save
# would replace the objects the next master build restores, and a PR that compiles a
# different configuration would evict the warm set for everyone. Tags build with full
# DWARF (COCKATRICE_DEBUG_INFO_LEVEL is empty), so a tag's cache would poison the -g1
# set if it shared the master scope; it does not, because the save is master-only. The
# nightly legs persist nothing at all (PERSIST_CCACHE is false) for the same reason:
# a schedule shares refs/heads/master with the push.
#
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "Delete remote compiler cache (ccache)" - name: "Delete remote compiler cache (ccache)"
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit
@ -413,7 +507,7 @@ jobs:
- name: "Save updated compiler cache (ccache)" - name: "Save updated compiler cache (ccache)"
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true'
uses: actions/cache/save@v6 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }} key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CACHE }}/.ccache path: ${{ env.CACHE }}/.ccache
@ -421,7 +515,7 @@ jobs:
- name: "Upload artifact" - name: "Upload artifact"
id: upload_artifact id: upload_artifact
if: matrix.package != 'skip' if: matrix.package != 'skip'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
archive: false archive: false
if-no-files-found: error if-no-files-found: error
@ -441,7 +535,7 @@ jobs:
- name: "Attest binary provenance" - name: "Attest binary provenance"
id: attestation id: attestation
if: steps.upload_release.outcome == 'success' if: steps.upload_release.outcome == 'success'
uses: actions/attest@v4 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
show-summary: false show-summary: false
subject-path: ${{ steps.build.outputs.path }} subject-path: ${{ steps.build.outputs.path }}
@ -466,7 +560,7 @@ jobs:
test: skip # Running tests on all distros is superfluous test: skip # Running tests on all distros is superfluous
- distro: Fedora - distro: Fedora
version: 43 # previous release, still covered by the nightly version: 44 # previous release, still covered by the nightly
package: RPM package: RPM
test: skip # Running tests on all distros is superfluous test: skip # Running tests on all distros is superfluous
@ -477,6 +571,28 @@ jobs:
package: DEB package: DEB
test: skip # Running tests on all distros is superfluous test: skip # Running tests on all distros is superfluous
# The one leg that runs the suite under AddressSanitizer and
# UndefinedBehaviorSanitizer. It runs nightly rather than on pull requests: it is
# not on the pull-request critical path (Windows sets that), and its 1.2G cache
# was the largest single entry in the 10 GB quota. Debian 13 and Fedora 45 keep
# covering the uninstrumented suite on pull requests, so nothing is lost there.
#
# It packages nothing. A sanitized Debug build is not something to ship; the
# pull-request Ubuntu 26.04 leg publishes this distro's DEB.
- distro: Ubuntu
version: 26.04
variant: sanitize # separate image, same distro as the coverage leg below
package: skip
sanitize: yes
# A sanitizer finding must not redden the nightly run: vcpkg-cache-import only
# imports the archives of a run whose conclusion is success. The job itself
# still reports failure.
allow-failure: yes
# Scratch only (nightly legs persist no cache); sized so the instrumented
# objects, which are larger than the 614 MB measured, do not thrash mid-build.
ccache_size: 1200M
# The one leg that runs instrumented for line coverage and publishes the report as # 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 # 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 because the two instruments answer different questions: the
@ -501,7 +617,7 @@ jobs:
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure needs: configure
# A nightly and a tag build cover the legs a pull request or a merge does not wait for: # A nightly and a tag build cover the legs a pull request or a merge does not wait for:
# the previous release of each distro, and the one instrument that only reports. # the previous release of each distro, and the instrumented legs.
if: github.event_name == 'schedule' || github.ref_type == 'tag' if: github.event_name == 'schedule' || github.ref_type == 'tag'
runs-on: ubuntu-latest # https://github.com/actions/runner-images runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }} continue-on-error: ${{ matrix.allow-failure == 'yes' }}
@ -514,10 +630,16 @@ jobs:
# A cold nightly build costs minutes on a runner nobody waits for; a cold pull request # 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. # build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir.
PERSIST_CCACHE: 'false' PERSIST_CCACHE: 'false'
CCACHE_SIZE: 600M CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }}
CMAKE_GENERATOR: 'Ninja' CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }} NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }} 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 steps: *linux-steps
build-vcpkg: build-vcpkg:
@ -530,6 +652,7 @@ jobs:
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
ccache_eviction_age: 7d ccache_eviction_age: 7d
ccache_size: 700M # Release + package: ~0.4 GB at level 0
cmake_generator: Ninja cmake_generator: Ninja
make_package: 1 make_package: 1
override_target: 14 override_target: 14
@ -548,6 +671,7 @@ jobs:
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
ccache_eviction_age: 7d ccache_eviction_age: 7d
ccache_size: 700M # Release + package: ~0.4 GB at level 0
cmake_generator: Ninja cmake_generator: Ninja
make_package: 1 make_package: 1
override_target: 15 override_target: 15
@ -565,6 +689,7 @@ jobs:
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
ccache_eviction_age: 7d ccache_eviction_age: 7d
ccache_size: 700M # Debug + suite: ~0.5 GB at level 0
cmake_generator: Ninja cmake_generator: Ninja
qt_version: 6.11.* qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
@ -589,6 +714,9 @@ jobs:
qt_version: 6.11.* qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
type: Release type: Release
ccache_eviction_age: 7d
ccache_size: 950M # Release plus the package: 890 MB working set without /Z7
use_ccache: 1
# Scope is named after the vs2026 toolchain, not the generator: the # Scope is named after the vs2026 toolchain, not the generator: the
# ABI hash covers cl.exe, which is identical, so this leg keeps # ABI hash covers cl.exe, which is identical, so this leg keeps
# restoring the warm archives. Renaming would orphan them. # restoring the warm archives. Renaming would orphan them.
@ -602,40 +730,60 @@ jobs:
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 100 timeout-minutes: 100
env: env:
# The Windows leg and all three pull-request macOS legs keep a compiler cache. ccache
# drives MSVC as long as the compiler emits /Z7, which CMakeLists.txt sets for exactly
# this reason, and it drives clang on macOS the same way; both cache the PCH-backed
# targets with the sloppiness that .ci/compile.sh re-asserts. Every leg builds without
# debug information on non-tag refs, so one macOS configuration writes ~0.4-0.5 GB and
# the 700M bound holds it with room for commit-to-commit churn (see build-linux for the
# quota arithmetic).
CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_DIR: ${{ github.workspace }}/.cache/
# macOS configurations compile about twice the TUs of a Linux one, so each of these CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
# three legs gets a bound above what it has been measured to write. The four macOS PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
# legs used to be bounded at 600M from the same quota as the Linux ones; the three # MSVC has no -g level, so there is nothing to ask for on Windows: level 0 is the one
# pull-request legs now get 700M each while the nightly Intel leg keeps none, and # value with a meaning there, and it drops /Z7 rather than lowering a level. Pull-request
# that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic). # builds carry no debug information, which is most of what a compiler cache stores; a
CCACHE_SIZE: 700M # tag is the build whose binary is shipped and symbolicated, so it keeps the full set.
PERSIST_CCACHE: 'true' COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '0' || '' }}
# Shared with build-vcpkg-nightly so the two leg sets run the same steps. # Shared with build-vcpkg-nightly so the two leg sets run the same steps.
steps: &vcpkg-steps steps: &vcpkg-steps
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
submodules: recursive submodules: recursive
- name: "[Windows] Add msbuild to PATH" - name: "[Windows] Add msbuild to PATH"
if: matrix.os == 'Windows' if: matrix.os == 'Windows'
uses: microsoft/setup-msbuild@v3 uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0
with: with:
msbuild-architecture: x64 msbuild-architecture: x64
- name: "[Windows] Setup ccache"
if: matrix.os == 'Windows' && matrix.use_ccache == 1
shell: bash
run: choco install ccache --no-progress -y
- name: "[macOS] Setup ccache" - name: "[macOS] Setup ccache"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 if: matrix.os == 'macOS' && matrix.use_ccache == 1
run: brew install ccache run: brew install ccache
- name: "[macOS] Restore compiler cache (ccache)" - name: "Restore compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
id: restore_ccache id: restore_ccache
uses: actions/cache/restore@v6 uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }} key: ccache-${{ matrix.os }}${{ matrix.target }}
path: ${{ env.CCACHE_DIR }} path: ${{ env.CCACHE_DIR }}
# Every macOS leg that keeps a cache restores the master entry for its own target.
# Measured on master 2026-11-07 (before the debug-information change), one macOS
# configuration wrote a 0.5 GB ccache directory at -g1 against the 700M bound, and the
# restored cache served 42% of calls. Level 0 shrinks that directory to ~0.4 GB, so the
# bound holds the whole configuration and the three entries fit the quota (see the
# sizing note in build-linux). macOS 14 and 15 build the same Release + package
# configuration for two deployment targets, but each is cheap enough to keep its own.
- name: "[macOS] Install aqtinstall" - name: "[macOS] Install aqtinstall"
if: matrix.os == 'macOS' if: matrix.os == 'macOS'
run: pipx install aqtinstall run: pipx install aqtinstall
@ -652,17 +800,18 @@ jobs:
- name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}" - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}"
if: matrix.os == 'macOS' if: matrix.os == 'macOS'
id: restore_qt id: restore_qt
uses: actions/cache/restore@v6 uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-${{ env.QT_ARCHIVES }}-thin
path: ${{ github.workspace }}/Qt path: ${{ github.workspace }}/Qt
# Using jurplel/install-qt-action to install Qt without using brew # Using jurplel/install-qt-action to install Qt without using brew
# Qt build using vcpkg either just fails or takes too long to build # Qt build using vcpkg either just fails or takes too long to build
- name: "[macOS] Install fat Qt ${{ matrix.qt_version }}" - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}"
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
uses: jurplel/install-qt-action@v4 uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0
with: with:
archives: ${{ env.QT_ARCHIVES }}
cache: false cache: false
# cache-key-prefix: Qt # cache-key-prefix: Qt
dir: ${{ github.workspace }} # thinning script depends on this location dir: ${{ github.workspace }} # thinning script depends on this location
@ -675,17 +824,18 @@ jobs:
- name: "[macOS] Cache thin Qt libraries" - name: "[macOS] Cache thin Qt libraries"
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master' if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master'
uses: actions/cache/save@v6 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
key: ${{ steps.restore_qt.outputs.cache-primary-key }} key: ${{ steps.restore_qt.outputs.cache-primary-key }}
path: ${{ github.workspace }}/Qt path: ${{ github.workspace }}/Qt
- name: "[Windows] Install Qt ${{ matrix.qt_version }}" - name: "[Windows] Install Qt ${{ matrix.qt_version }}"
if: matrix.os == 'Windows' if: matrix.os == 'Windows'
uses: jurplel/install-qt-action@v4 uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0
with: with:
# Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released
aqtsource: git+https://github.com/miurahr/aqtinstall.git aqtsource: git+https://github.com/miurahr/aqtinstall.git
archives: ${{ env.QT_ARCHIVES }}
cache: ${{ github.ref == 'refs/heads/master' }} cache: ${{ github.ref == 'refs/heads/master' }}
cache-key-prefix: Qt cache-key-prefix: Qt
modules: ${{ matrix.qt_modules }} modules: ${{ matrix.qt_modules }}
@ -694,7 +844,7 @@ jobs:
- name: "[Windows] Install NSIS" - name: "[Windows] Install NSIS"
if: matrix.os == 'Windows' if: matrix.os == 'Windows'
shell: bash shell: bash
run: choco install nsis run: choco install nsis --no-progress
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH. # Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
- name: "[Windows] Set up MSVC environment" - name: "[Windows] Set up MSVC environment"
@ -705,7 +855,7 @@ jobs:
- name: "Setup vcpkg cache" - name: "Setup vcpkg cache"
id: vcpkg-cache id: vcpkg-cache
uses: TAServers/vcpkg-cache@v3 uses: TAServers/vcpkg-cache@4f07be616a4604dd29f3c6885975cf2c04abee71 # v3.4.2
with: with:
# Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform, # 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 # target and toolchain. The action restores every entry it finds under the
@ -774,6 +924,21 @@ jobs:
WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }} WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
run: .ci/compile.sh --server --test --vcpkg run: .ci/compile.sh --server --test --vcpkg
# What the compiler cache has to hold for this leg. ccache stores the objects it
# compiles, so their total is the working set a bound has to fit, and the cache
# directory shows what this build actually wrote. Debug information dominates both,
# which is why the level above is reported next to them.
- name: "Report compiler cache footprint"
if: always()
shell: bash
run: |
echo "=== ccache directory"
du -sh "$CCACHE_DIR" 2>/dev/null || echo "(none)"
echo "=== objects and archives"
find build -type f \( -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.lib' \) -exec du -k {} + 2>/dev/null \
| awk '{n++; s+=$1} END {printf "%d files, %.2f GB\n", n, s/1048576}'
echo "=== debug info level in use: '${COCKATRICE_DEBUG_INFO_LEVEL}'"
- name: "Install sentry-cli" - name: "Install sentry-cli"
if: needs.configure.outputs.tag != null && matrix.make_package == '1' 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 # Same as the Linux leg above: the install downloads from Sentry's registry, so a
@ -950,7 +1115,7 @@ jobs:
- name: "Upload new vcpkg entries" - name: "Upload new vcpkg entries"
# An empty count means the step above did not run at all, on master and on forks # 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' if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
if-no-files-found: error # a count above zero with nothing staged is a bug if-no-files-found: error # a count above zero with nothing staged is a bug
name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }} name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
@ -958,9 +1123,11 @@ jobs:
retention-days: 1 retention-days: 1
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "[macOS] Delete remote compiler cache (ccache)" - name: "Delete remote compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true continue-on-error: true
# Bash, not the Windows runner's default pwsh, which cannot parse this `if`.
shell: bash
env: env:
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@ -969,9 +1136,9 @@ jobs:
echo "Cache deleted successfully" echo "Cache deleted successfully"
fi fi
- name: "[macOS] Cache updated compiler cache (ccache)" - name: "Cache updated compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
uses: actions/cache/save@v6 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }} key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CCACHE_DIR }} path: ${{ env.CCACHE_DIR }}
@ -1026,7 +1193,7 @@ jobs:
- name: "Upload artifact" - name: "Upload artifact"
if: matrix.make_package if: matrix.make_package
id: upload_artifact id: upload_artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
archive: false archive: false
if-no-files-found: error if-no-files-found: error
@ -1058,7 +1225,7 @@ jobs:
# debug identifier, and the PE is what carries the unwind information. # debug identifier, and the PE is what carries the unwind information.
- name: "[Windows] Upload debug symbols (exe + pdb)" - name: "[Windows] Upload debug symbols (exe + pdb)"
if: matrix.os == 'Windows' && needs.configure.outputs.tag != null if: matrix.os == 'Windows' && needs.configure.outputs.tag != null
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
if-no-files-found: error if-no-files-found: error
name: ${{ steps.build.outputs.name }}-Symbols name: ${{ steps.build.outputs.name }}-Symbols
@ -1073,7 +1240,7 @@ jobs:
- name: "Attest binary provenance" - name: "Attest binary provenance"
if: steps.upload_release.outcome == 'success' if: steps.upload_release.outcome == 'success'
id: attestation id: attestation
uses: actions/attest@v4 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
show-summary: false show-summary: false
subject-path: ${{ steps.build.outputs.path }} subject-path: ${{ steps.build.outputs.path }}
@ -1096,7 +1263,6 @@ jobs:
runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md 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 # 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 cmake_generator: Ninja
make_package: 1 make_package: 1
override_target: 13 override_target: 13
@ -1105,7 +1271,6 @@ jobs:
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Intel soc: Intel
type: Release type: Release
use_ccache: 1
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
@ -1116,12 +1281,6 @@ jobs:
if: github.event_name == 'schedule' || github.ref_type == 'tag' if: github.event_name == 'schedule' || github.ref_type == 'tag'
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 100 timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
# Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with
# the pull-request legs (see PERSIST_CCACHE there).
PERSIST_CCACHE: 'false'
CCACHE_SIZE: 600M
steps: *vcpkg-steps steps: *vcpkg-steps
prune-caches: prune-caches:
@ -1149,6 +1308,16 @@ jobs:
# age is the right test. Dropping a blob an index still lists costs the next image build # 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 # one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the
# entries forever. # entries forever.
#
# codeql.yml's analyze job keeps its extracted database as one entry per analyzed commit,
# keyed codeql-trap-<schema>-<version>-<language>-<sha>, about 1.07 GB each. Each run
# restores the newest entry by prefix and saves its own, so every run that analyzes a new
# commit makes the previous entry dead, and the action never deletes them: four had
# accumulated by 2026-11-07 (4.26 GB, 40% of the quota). Retiring them cannot wait for
# the age rule, because they are not the oldest entries and so not what least-recently-
# used eviction reaches: the buildkit layers are. Only the newest can be restored again,
# and a miss costs one re-extraction in a job that reports rather than gates, so keeping
# exactly that one is safe.
- name: "Delete unusable cache entries" - name: "Delete unusable cache entries"
shell: bash shell: bash
env: env:
@ -1159,9 +1328,10 @@ jobs:
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' 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 # 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. # (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 # The nightly legs and the pull-request legs that finish under the Windows leg
# instead of waiting out the age rule. # keep none, so their leftover entries are reclaimed here instead of waiting out
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' # the age rule.
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10 ccache-macOS14 ccache-macOS15 ccache-macOS27'
run: | run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ 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" --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
@ -1169,15 +1339,19 @@ jobs:
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))" cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
live=" ${LIVE_VCPKG_SCOPES} " live=" ${LIVE_VCPKG_SCOPES} "
live_ccache=" ${LIVE_CCACHE_KEYS} " live_ccache=" ${LIVE_CCACHE_KEYS} "
# The one trap cache a later run can still restore, by prefix. ISO-8601 sorts
# lexicographically, so this is a plain string compare. Entries scoped to another
# ref are deleted by the ref rule below whatever this says.
newest_trap="$(awk -F'\t' '$2 == "refs/heads/master" && $3 ~ /^codeql-trap-/ && $4 > m { m = $4; k = $3 } END { print k }' "$RUNNER_TEMP/caches.tsv")"
deleted=0 deleted=0
while IFS=$'\t' read -r id ref key accessed; do while IFS=$'\t' read -r id ref key accessed; do
[[ -n $id ]] || continue [[ -n $id ]] || continue
# Only ever touch the key shapes our workflows create (buildkit-* and index-* # 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 # come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml),
# never be deleted by accident # so that an entry of some other workflow can never be deleted by accident
case "$key" in case "$key" in
vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;; vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;;
*) continue ;; *) continue ;;
esac esac
@ -1185,8 +1359,9 @@ jobs:
if [[ $ref != refs/heads/master ]]; then if [[ $ref != refs/heads/master ]]; then
reason="scoped to $ref" reason="scoped to $ref"
elif [[ $key == ccache-* ]]; then elif [[ $key == ccache-* ]]; then
# An exact token compare: ccache-Ubuntu26.04 is a prefix of # An exact token compare, because one key can be a prefix of another:
# ccache-Ubuntu26.04sanitize, and both keys are live # ccache-Fedora45 is a prefix of ccache-Fedora45debug and
# ccache-Fedora45release, and only the latter two are live.
if [[ $live_ccache != *" $key "* ]]; then if [[ $live_ccache != *" $key "* ]]; then
reason="no pull-request leg keeps it" reason="no pull-request leg keeps it"
fi fi
@ -1197,6 +1372,10 @@ jobs:
if [[ $live != *" $scope "* ]]; then if [[ $live != *" $scope "* ]]; then
reason="no matrix leg uses $scope" reason="no matrix leg uses $scope"
fi fi
elif [[ $key == codeql-trap-* ]]; then
if [[ $key != "$newest_trap" ]]; then
reason="superseded by the newest trap cache"
fi
fi fi
if [[ -z $reason ]]; then if [[ -z $reason ]]; then
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)" stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"

View file

@ -1,7 +1,7 @@
name: Code Style (C++) name: Code Style (C++)
permissions: permissions:
contents: read contents: read # needed to checkout repo
on: on:
# Push trigger not needed for linting, we do not allow direct pushes to master # Push trigger not needed for linting, we do not allow direct pushes to master
@ -12,7 +12,7 @@ on:
- '!.ci/**' - '!.ci/**'
- '!.github/**' - '!.github/**'
- '!.tx/**' - '!.tx/**'
- '!doc/**' - '!docs/**'
- '.ci/lint_cpp.sh' - '.ci/lint_cpp.sh'
- '.github/workflows/desktop-lint.yml' - '.github/workflows/desktop-lint.yml'
- '.clang-format' - '.clang-format'
@ -21,11 +21,12 @@ on:
jobs: jobs:
format: format:
runs-on: ubuntu-slim name: "Check formatting"
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
fetch-depth: 20 # should be enough to find merge base fetch-depth: 20 # should be enough to find merge base

View file

@ -56,7 +56,7 @@ jobs:
include: include:
- label: x86 - label: x86
platform: linux/amd64 platform: linux/amd64
runner: ubuntu-latest # https://github.com/actions/runner-images runner: ubuntu-latest # https://github.com/actions/runner-images#available-images
- label: arm - label: arm
platform: linux/arm64 platform: linux/arm64
@ -67,17 +67,17 @@ jobs:
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }} ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }}
- name: "Set up Docker buildx" - name: "Set up Docker buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: "Login to GitHub Container Registry (GHCR)" - name: "Login to GitHub Container Registry (GHCR)"
if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch'
id: login id: login
uses: docker/login-action@v4 uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -92,7 +92,7 @@ jobs:
# cache held. The final runtime stage is the part that still pays off. # cache held. The final runtime stage is the part that still pays off.
- name: "Build image" - name: "Build image"
if: steps.login.outcome != 'success' if: steps.login.outcome != 'success'
uses: docker/build-push-action@v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }} cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }}
@ -104,7 +104,7 @@ jobs:
- name: "Build image and push by digest" - name: "Build image and push by digest"
if: steps.login.outcome == 'success' if: steps.login.outcome == 'success'
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }} cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }}
@ -115,7 +115,7 @@ jobs:
org.opencontainers.image.url=${{ env.OCI_URL }} org.opencontainers.image.url=${{ env.OCI_URL }}
outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
provenance: mode=max # Do not pass secrets as build arguments with this option provenance: mode=max # do not pass secrets as build arguments with this option
sbom: true sbom: true
- name: "Export digest" - name: "Export digest"
@ -128,7 +128,7 @@ jobs:
- name: "Upload digest" - name: "Upload digest"
if: steps.login.outcome == 'success' if: steps.login.outcome == 'success'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
if-no-files-found: error if-no-files-found: error
name: digest-${{ matrix.label }} name: digest-${{ matrix.label }}
@ -145,14 +145,14 @@ jobs:
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digest-* pattern: digest-*
merge-multiple: true merge-multiple: true
- name: "Login to GitHub Container Registry (GHCR)" - name: "Login to GitHub Container Registry (GHCR)"
uses: docker/login-action@v4 uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -160,7 +160,7 @@ jobs:
- name: "Docker metadata" - name: "Docker metadata"
id: metadata id: metadata
uses: docker/metadata-action@v6 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: ${{ env.GHCR_IMAGE }} images: ${{ env.GHCR_IMAGE }}
flavor: | flavor: |

View file

@ -1,4 +1,4 @@
name: Generate Docs name: Build Documentation
permissions: permissions:
contents: read # write permission to the destination repo come from 'deploy_key' contents: read # write permission to the destination repo come from 'deploy_key'
@ -6,7 +6,7 @@ permissions:
on: on:
pull_request: pull_request:
paths: paths:
- 'doc/doxygen/**' - 'docs/**'
- '.github/workflows/documentation-build.yml' - '.github/workflows/documentation-build.yml'
- 'Doxyfile' - 'Doxyfile'
release: release:
@ -24,12 +24,12 @@ env:
jobs: jobs:
docs: docs:
name: Doxygen name: "Doxygen"
runs-on: ubuntu-latest runs-on: ubuntu-latest # https://github.com/actions/runner-images#available-images
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
submodules: recursive submodules: recursive
@ -40,9 +40,9 @@ jobs:
dot -V dot -V
- name: "Install Doxygen" - name: "Install Doxygen"
uses: ssciwr/doxygen-install@v2 uses: ssciwr/doxygen-install@fa3e94d34131d3990b6b8ba939ab982964aa3758 # v2.0.3
with: with:
version: "1.18.0" version: "1.18.0" # https://www.doxygen.nl/manual/changelog.html
- name: "Update Doxygen Configuration" - name: "Update Doxygen Configuration"
shell: bash shell: bash
@ -65,13 +65,13 @@ jobs:
- name: "Deploy to cockatrice.github.io/docs" - name: "Deploy to cockatrice.github.io/docs"
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
uses: peaceiris/actions-gh-pages@v4 uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
with: with:
deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }} deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }}
destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/ destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/
external_repository: Cockatrice/cockatrice.github.io external_repository: Cockatrice/cockatrice.github.io
publish_branch: master publish_branch: master
publish_dir: ./docs/html publish_dir: ./docs-output/html
- name: Link to Documentation Page - name: Link to Documentation Page
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'

View file

@ -16,20 +16,19 @@ on:
jobs: jobs:
translations: translations:
name: "Pull languages"
# Do not run the scheduled workflow on forks # Do not run the scheduled workflow on forks
if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice'
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
name: Pull languages
runs-on: ubuntu-slim
steps: steps:
- name: "Checkout repo" - name: "Checkout repo"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Pull translated strings from Transifex" - name: "Pull translated strings from Transifex"
# Do not run this step for PR's from forks, they don't have access to the secret # Do not run this step for PR's from forks, they don't have access to the secret
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: transifex/cli-action@v2 uses: transifex/cli-action@584fd205cbe598773b5a81ce711fa44842678189 # v2
with: with:
# Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config # Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config
# Docs: https://github.com/transifex/cli#pulling-files-from-transifex # Docs: https://github.com/transifex/cli#pulling-files-from-transifex
@ -39,7 +38,7 @@ jobs:
- name: "Create pull request" - name: "Create pull request"
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
id: create_pr id: create_pr
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with: with:
add-paths: | add-paths: |
cockatrice/translations/*.ts cockatrice/translations/*.ts

View file

@ -16,15 +16,14 @@ on:
jobs: jobs:
translations: translations:
name: "Push strings"
# Do not run the scheduled workflow on forks # Do not run the scheduled workflow on forks
if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice'
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
name: Push strings
runs-on: ubuntu-slim
steps: steps:
- name: "Checkout repo" - name: "Checkout repo"
uses: actions/checkout@v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Install lupdate" - name: "Install lupdate"
shell: bash shell: bash
@ -37,20 +36,22 @@ jobs:
FILE: cockatrice/cockatrice_en@source.ts FILE: cockatrice/cockatrice_en@source.ts
id: cockatrice id: cockatrice
shell: bash shell: bash
run: > run: |
DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')" export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
.ci/update_translation_source_strings.sh .ci/update_translation_source_strings.sh
- name: "Update Oracle translation source" - name: "Update Oracle translation source"
id: oracle id: oracle
shell: bash shell: bash
env: env:
DIRS: 'oracle/src' DIRS: oracle/src
FILE: 'oracle/oracle_en@source.ts' FILE: oracle/oracle_en@source.ts
run: .ci/update_translation_source_strings.sh run: .ci/update_translation_source_strings.sh
- name: "Render template" - name: "Render template"
id: template id: template
# Not pinnable to a SHA: the action downloads its release binary by ref name,
# so a commit ref matches no release tag.
uses: chuhlomin/render-template/binary@v1 uses: chuhlomin/render-template/binary@v1
with: with:
template: .ci/update_translation_source_strings_template.md template: .ci/update_translation_source_strings_template.md
@ -62,7 +63,7 @@ jobs:
- name: "Create pull request" - name: "Create pull request"
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
id: create_pr id: create_pr
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with: with:
add-paths: | add-paths: |
cockatrice/cockatrice_en@source.ts cockatrice/cockatrice_en@source.ts

View file

@ -23,8 +23,10 @@ permissions:
on: on:
workflow_run: workflow_run:
workflows: ["Build Desktop"] workflows:
types: [completed] - 'Build Desktop'
types:
- completed
# Every import writes the same scopes, so let them queue instead of racing each other # Every import writes the same scopes, so let them queue instead of racing each other
concurrency: concurrency:
@ -33,12 +35,12 @@ concurrency:
jobs: jobs:
list: list:
name: List new vcpkg entries name: "List new vcpkg entries"
# Only a successful push to the default branch has archives worth importing. # 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. # 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 # Bot actors are excluded on top: their runs must never promote binaries into
# the shared scope even if they somehow target the default branch. # the shared scope even if they somehow target the default branch.
if: >- if: |
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.conclusion == 'success' &&
(github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') && (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') &&
github.event.workflow_run.head_branch == 'master' && github.event.workflow_run.head_branch == 'master' &&
@ -46,6 +48,7 @@ jobs:
!endsWith(github.event.workflow_run.actor.login, '[bot]') && !endsWith(github.event.workflow_run.actor.login, '[bot]') &&
!endsWith(github.event.workflow_run.triggering_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 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 # 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, # 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. # because a save under read-only access only warns and would import nothing silently.
@ -55,7 +58,7 @@ jobs:
steps: steps:
- name: "Download new vcpkg entries" - name: "Download new vcpkg entries"
uses: actions/download-artifact@v8 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
pattern: vcpkg-new-* pattern: vcpkg-new-*
path: incoming path: incoming
@ -66,7 +69,7 @@ jobs:
shell: bash shell: bash
run: | run: |
if [[ $ACTIONS_CACHE_MODE != *write* ]]; then if [[ $ACTIONS_CACHE_MODE != *write* ]]; then
echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" echo "::error::Expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
exit 1 exit 1
fi fi
@ -88,14 +91,16 @@ jobs:
# Compact, because a multi line value cannot be written to GITHUB_OUTPUT # Compact, because a multi line value cannot be written to GITHUB_OUTPUT
entries="$(jq -cs '.' <<<"$entries")" entries="$(jq -cs '.' <<<"$entries")"
echo "entries=$entries" >>"$GITHUB_OUTPUT" echo "entries=$entries" >>"$GITHUB_OUTPUT"
echo "::notice::importing $(jq length <<<"$entries") vcpkg entries" echo "::notice::Importing $(jq length <<<"$entries") vcpkg entries"
save: save:
name: "Save ${{ matrix.entry.key }}" name: "Save ${{ matrix.entry.key }}"
needs: list
if: needs.list.outputs.entries if: needs.list.outputs.entries
runs-on: ubuntu-slim needs: list
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
cache-mode: write cache-mode: write
strategy: strategy:
# One job per archive, they run in parallel and each only uploads a single file # One job per archive, they run in parallel and each only uploads a single file
fail-fast: false fail-fast: false
@ -105,7 +110,7 @@ jobs:
steps: steps:
- name: "Download new vcpkg entries" - name: "Download new vcpkg entries"
uses: actions/download-artifact@v8 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
pattern: ${{ matrix.entry.artifact }} pattern: ${{ matrix.entry.artifact }}
path: incoming path: incoming
@ -113,7 +118,7 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: "Save vcpkg entry" - name: "Save vcpkg entry"
uses: actions/cache/save@v6 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
# The payload is a Windows or macOS archive while the upload runs on Linux, and # 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 # without this the entry would be stamped as a Linux cache that neither of them

2
.gitignore vendored
View file

@ -15,4 +15,4 @@ compile_commands.json
.cache .cache
.gdb_history .gdb_history
cockatrice/resources/config/qtlogging.ini cockatrice/resources/config/qtlogging.ini
docs/ docs-output/

2
.gitmodules vendored
View file

@ -2,7 +2,7 @@
path = vcpkg path = vcpkg
url = https://github.com/microsoft/vcpkg.git url = https://github.com/microsoft/vcpkg.git
[submodule "doxygen-awesome-css"] [submodule "doxygen-awesome-css"]
path = doc/doxygen/theme path = docs/doxygen/theme
url = https://github.com/jothepro/doxygen-awesome-css.git url = https://github.com/jothepro/doxygen-awesome-css.git
[submodule "external/qttesting"] [submodule "external/qttesting"]
path = external/qttesting path = external/qttesting

View file

@ -1,11 +1,11 @@
# Cockatrice's main CMakeLists.txt # Cockatrice's main CMakeLists.txt
# #
# This is basically a wrapper to enable/disable the compilation # This is basically a wrapper to enable/disable the compilation of the different projects:
# of the different projects: servatrice, cockatrice, test # Cockatrice, Oracle, Servatrice, Test
# This file sets all the variables shared between the projects # This file sets all the variables shared between the projects
# like the installation path, compilation flags etc.. # like the installation path, compilation flags etc..
# 3.16 required for Qt6 and target_precompile_headers() # CMake 3.16 is required for Qt6 and target_precompile_headers()
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
# Use compiler cache (ccache) # Use compiler cache (ccache)
@ -40,24 +40,20 @@ if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/
endif() endif()
# Default to "Release" build type # Default to "Release" build type
# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call # A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call
if(DEFINED CMAKE_BUILD_TYPE) if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE
${CMAKE_BUILD_TYPE}
CACHE STRING "Type of build"
)
else()
set(CMAKE_BUILD_TYPE set(CMAKE_BUILD_TYPE
Release Release
CACHE STRING "Type of build" CACHE STRING "Build type"
) )
endif() endif()
# ccache does not support MSVC and must not auto-engage on Windows # ccache drives MSVC too (since 4.7), but only when the compiler writes debug
# (it is installed unintentionally on the Windows CI runner). # information into the object file: /Zi and /ZI keep it in a shared PDB, and ccache
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows # reports them as unsupported rather than risk a stale one. The MSVC flags below
# also opts out of ccache even though the GNUCXX branch below supports it. # therefore use /Z7. MSVC precompiled headers are cached as well, with the same
if(USE_CCACHE AND NOT WIN32) # sloppiness the other toolchains need.
if(USE_CCACHE)
find_program(CCACHE_PROGRAM ccache) find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM) if(CCACHE_PROGRAM)
# Support Unix Makefiles and Ninja # Support Unix Makefiles and Ninja
@ -71,10 +67,10 @@ if(USE_CCACHE AND NOT WIN32)
# check for every project on the machine, not just this one. # check for every project on the machine, not just this one.
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
message(STATUS "Found CCache ${CCACHE_PROGRAM}") message(STATUS "Found CCache ${CCACHE_PROGRAM}")
else()
# An explicit opt-in must not disappear silently.
message(STATUS "ccache requested but not found; building without a compiler cache")
endif() endif()
elseif(USE_CCACHE AND WIN32)
# An explicit opt-in must not disappear silently on Windows.
message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
endif() endif()
if(WIN32 OR USE_VCPKG) if(WIN32 OR USE_VCPKG)
@ -108,20 +104,21 @@ endif()
# Version can be overriden by git tags, see cmake/getversion.cmake # Version can be overriden by git tags, see cmake/getversion.cmake
project("Cockatrice" VERSION 3.2.0) project("Cockatrice" VERSION 3.2.0)
# Set release name if not provided via env/cmake var # Set release name if not provided via ENV/CMake var
if(NOT DEFINED GIT_TAG_RELEASENAME) if(NOT DEFINED GIT_TAG_RELEASENAME)
set(GIT_TAG_RELEASENAME "Reality Check") set(GIT_TAG_RELEASENAME "Reality Check")
endif() endif()
# Use c++20 for all targets # Use C++20 for all targets
set(CMAKE_CXX_STANDARD set(CMAKE_CXX_STANDARD
20 20
CACHE STRING "C++ ISO Standard" CACHE STRING "C++ ISO Standard"
) )
set(CMAKE_CXX_STANDARD_REQUIRED True) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Set conventional loops # Set conventional loops
set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true) set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON)
# Search path for cmake modules # Search path for cmake modules
set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake") set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake")
@ -134,31 +131,31 @@ include(createversionfile)
# Define a proper install path # Define a proper install path
if(UNIX) if(UNIX)
if(APPLE) if(APPLE) # macOS
# macOS # Due to the special bundle structure ignore the prefix eventually set by the user
# Due to the special bundle structure ignore
# the prefix eventually set by the user.
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release) set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release)
# Force ccache usage if available # Force ccache usage if requested and available
get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE) if(USE_CCACHE)
if(RULE_LAUNCH_COMPILE) get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
message(STATUS "Force enabling CCache usage under macOS") if(RULE_LAUNCH_COMPILE)
# Set up wrapper scripts message(STATUS "Force enabling ccache usage under macOS")
configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c)
configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx)
execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx")
# Set Xcode project attributes to route compilation through our scripts # Set up wrapper scripts
set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c") configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c)
set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx") configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx)
set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c") execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx")
set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx")
# Set Xcode project attributes to route compilation through our scripts
set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c")
set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx")
set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c")
set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx")
endif()
endif() endif()
else() else() # Linux / BSD
# Linux / BSD
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
#fix package build # Fix package build
if(PREFIX) if(PREFIX)
set(CMAKE_INSTALL_PREFIX ${PREFIX}) set(CMAKE_INSTALL_PREFIX ${PREFIX})
else() else()
@ -166,47 +163,68 @@ if(UNIX)
endif() endif()
endif() endif()
endif() endif()
elseif(WIN32) elseif(WIN32) # Windows (including 64-bit)
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE}) set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE})
endif() endif()
# Define proper compilation flags # Define compiler flags
if(MSVC) if(MSVC) # Microsoft Visual C++ compiler
# Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols # /EHsc Enable standard C++ exception handling
set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi") # /MP Enable parallel compilation
# Visual Studio: Maximum Optimization, Multi-threaded DLL # /permissive- Enable more standards-conforming behavior
set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD") # /utf-8 Set source file encoding and execution character set to UTF-8
# Visual Studio: No Optimization, Multi-threaded Debug DLL # /W4 Enable warning level 4
# /Zc:__cplusplus Enable correct __cplusplus reporting
# /Z7 Generate debugging information in each object file (ccache-cacheable)
# instead of a compiler-shared PDB, which ccache rejects
set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Z7")
# /O2 Balanced optimization
# /MD Link against the multi-threaded DLL runtime library (Release CRT)
set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD")
# /Od Disable optimization
# /MDd Link against the multi-threaded Debug DLL runtime library (Debug CRT)
set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd") set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd")
# Generate PDB, even when in release (So developers can better analyze crash logs) # Generate PDBs, even in release builds, so developers can better analyze crash logs
# /DEBUG Enable debug symbols and PDB generation
# /OPT:REF Remove unreferenced functions/data
# /OPT:ICF Fold identical COMDAT functions/data
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF") set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")
add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING) add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING)
elseif(CMAKE_COMPILER_IS_GNUCXX) elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler
# linux/gcc, bsd/gcc, windows/mingw
include(CheckCXXCompilerFlag) include(CheckCXXCompilerFlag)
set(CMAKE_CXX_FLAGS_RELEASE "-s -O2") # -O2 Balanced optimization
# -s Remove symbols from the executable
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -s")
# -ggdb Produce GDB debugging symbols
# -O0 Disable optimization
# -Wall Enable a broad set of useful warnings
# -Wextra Enable a set of extra warnings
# -Werror Treat warnings as compilation errors
if(WARNING_AS_ERROR) if(WARNING_AS_ERROR)
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror") set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror")
else() else()
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra") set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra")
endif() endif()
if(APPLE) # -Wcast-align Catch unsafe pointer casts
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20") # -Wmissing-declarations Catch defined functions without a prior declaration
endif() # -Wno-error=extra Downgrade some -Wextra warnings from errors to warnings
# -Wno-error=delete-non-virtual-dtor Downgrade delete-non-virtual-dtor from error to warning
# -Wno-error=sign-compare Downgrade signed/unsigned comparison from error to warning
# -Wno-error=missing-declarations Downgrade -Wmissing-declarations from error to warning
# -Wno-error=sfinae-incomplete GCC 16+: Qt MOC + protobuf forward decls trigger this
set(ADDITIONAL_DEBUG_FLAGS set(ADDITIONAL_DEBUG_FLAGS
-Wcast-align -Wcast-align
-Wmissing-declarations -Wmissing-declarations
-Wno-long-long
-Wno-error=extra -Wno-error=extra
-Wno-error=delete-non-virtual-dtor -Wno-error=delete-non-virtual-dtor
-Wno-error=sign-compare -Wno-error=sign-compare
-Wno-error=missing-declarations -Wno-error=missing-declarations
-Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this -Wno-error=sfinae-incomplete
) )
foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS}) foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS})
@ -218,9 +236,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
# Reduce compiler I/O by using pipes between stages instead of temp files # Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else() elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers
# other: osx/llvm, bsd/llvm # -O2 Balanced optimization
set(CMAKE_CXX_FLAGS_RELEASE "-O2") set(CMAKE_CXX_FLAGS_RELEASE "-O2")
# -g Include debug information
# -O0 Disable optimization
# -Wall Enable a broad set of useful warnings
# -Wextra Enable a set of extra warnings
# -Werror Treat warnings as compilation errors
# -Wno-unused-parameter Suppress warnings about unused function parameters (common in Qt callbacks)
if(WARNING_AS_ERROR) if(WARNING_AS_ERROR)
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter") set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter")
else() else()
@ -229,6 +254,8 @@ else()
# Reduce compiler I/O by using pipes between stages instead of temp files # Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else() # Unknown compiler
message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
endif() endif()
# Always included, because they define cockatrice_disable_sanitizers_for() and # Always included, because they define cockatrice_disable_sanitizers_for() and
@ -238,9 +265,37 @@ include(Sanitizers)
include(Coverage) include(Coverage)
include(CrashReporting) include(CrashReporting)
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning # Debug information is what makes a cached object large, and a build configuration needs more
# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to
# its limit and evicts the entries the next build is about to ask for, so it serves a few
# percent of calls while holding most of a build. CI sets a lower level so one whole
# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer
# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as
# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot
# raise the level again.
set(COCKATRICE_DEBUG_INFO_LEVEL
""
CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)"
)
if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "")
if(MSVC)
# MSVC has no -g level, so /Z7 is either there or it is not. Level 0 is the only value
# that means something here: without /Z7 the objects carry no debug information, which is
# what a cache that has to fit a bound holds. Anything else is the full debug info.
if(COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "0")
string(REPLACE " /Z7" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
else()
message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC except 0")
endif()
else()
string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}")
string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}")
endif()
endif()
# GNU systems need to define the Mersenne exponent for SFMT, so the RNG compiles without warnings
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_definitions("-DSFMT_MEXP=19937") add_compile_definitions(SFMT_MEXP=19937)
endif() endif()
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
@ -293,15 +348,15 @@ if(${Protobuf_VERSION} VERSION_LESS "3.21.0.0" AND NOT EXISTS "${Protobuf_PROTOC
message(FATAL_ERROR "No protoc command found!") message(FATAL_ERROR "No protoc command found!")
endif() endif()
#Find OpenSSL # Find OpenSSL
if(WIN32) if(WIN32) # Windows (including 64-bit)
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND) if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS}) include_directories(${OPENSSL_INCLUDE_DIRS})
endif() endif()
endif() endif()
#Find VCredist # Find Visual C++ Redistributable
if(MSVC) if(MSVC)
find_package(VCredistRuntime) find_package(VCredistRuntime)
endif() endif()
@ -514,3 +569,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
# Qt6.3+ requires project finalization to support translations # Qt6.3+ requires project finalization to support translations
qt_finalize_project() qt_finalize_project()
endif() endif()
# Print compiler identification at configuration time
message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
message(STATUS "C++ compiler version: ${CMAKE_CXX_COMPILER_VERSION}")
message(STATUS "C++ compiler path: ${CMAKE_CXX_COMPILER}")
message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}")
message(STATUS "C++ extensions: ${CMAKE_CXX_EXTENSIONS}")

View file

@ -74,7 +74,7 @@ PROJECT_ICON = cockatrice/resources/cockatrice.png
# entered, it will be relative to the location where Doxygen was started. If # entered, it will be relative to the location where Doxygen was started. If
# left blank the current directory will be used. # left blank the current directory will be used.
OUTPUT_DIRECTORY = docs OUTPUT_DIRECTORY = docs-output
# If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096 # If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096
# sub-directories (in 2 levels) under the output directory of each output format # sub-directories (in 2 levels) under the output directory of each output format
@ -874,7 +874,7 @@ FILE_VERSION_FILTER =
# DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE # DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE
# tag is left empty. # tag is left empty.
LAYOUT_FILE = doc/doxygen/DoxygenLayout.xml LAYOUT_FILE = docs/doxygen/DoxygenLayout.xml
# The CITE_BIB_FILES tag can be used to specify one or more bib files containing # The CITE_BIB_FILES tag can be used to specify one or more bib files containing
# the reference definitions. This must be a list of .bib files. The .bib # the reference definitions. This must be a list of .bib files. The .bib
@ -1091,8 +1091,8 @@ RECURSIVE = YES
EXCLUDE = build/ \ EXCLUDE = build/ \
cmake/ \ cmake/ \
cmake-build-debug/ \ cmake-build-debug/ \
doc/doxygen/theme/docs/ \ docs/doxygen/theme/docs/ \
doc/doxygen/theme/include/ \ docs/doxygen/theme/include/ \
vcpkg/ vcpkg/
# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or # The EXCLUDE_SYMLINKS tag can be used to select whether or not files or
@ -1144,7 +1144,7 @@ EXAMPLE_RECURSIVE = NO
# that contain images that are to be included in the documentation (see the # that contain images that are to be included in the documentation (see the
# \image command). # \image command).
IMAGE_PATH = doc/doxygen/images IMAGE_PATH = docs/doxygen/images
# The INPUT_FILTER tag can be used to specify a program that Doxygen should # The INPUT_FILTER tag can be used to specify a program that Doxygen should
# invoke to filter for each input file. Doxygen will invoke the filter program # invoke to filter for each input file. Doxygen will invoke the filter program
@ -1183,7 +1183,7 @@ INPUT_FILTER =
# need to set EXTENSION_MAPPING for the extension otherwise the files are not # need to set EXTENSION_MAPPING for the extension otherwise the files are not
# properly processed by Doxygen. # properly processed by Doxygen.
FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py" FILTER_PATTERNS = "*.proto=python docs/doxygen/filters/proto2cpp.py"
# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using # If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using
# INPUT_FILTER) will also be used to filter the input files that are used for # INPUT_FILTER) will also be used to filter the input files that are used for
@ -1414,7 +1414,7 @@ HTML_FILE_EXTENSION = .html
# of the possible markers and block names see the documentation. # of the possible markers and block names see the documentation.
# This tag requires that the tag GENERATE_HTML is set to YES. # This tag requires that the tag GENERATE_HTML is set to YES.
HTML_HEADER = doc/doxygen/html/header.html HTML_HEADER = docs/doxygen/html/header.html
# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each # The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each
# generated HTML page. If the tag is left blank Doxygen will generate a standard # generated HTML page. If the tag is left blank Doxygen will generate a standard
@ -1424,7 +1424,7 @@ HTML_HEADER = doc/doxygen/html/header.html
# that Doxygen normally uses. # that Doxygen normally uses.
# This tag requires that the tag GENERATE_HTML is set to YES. # This tag requires that the tag GENERATE_HTML is set to YES.
HTML_FOOTER = doc/doxygen/html/footer.html HTML_FOOTER = docs/doxygen/html/footer.html
# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style # The HTML_STYLESHEET tag can be used to specify a user-defined cascading style
# sheet that is used by each HTML page. It can be used to fine-tune the look of # sheet that is used by each HTML page. It can be used to fine-tune the look of
@ -1454,9 +1454,9 @@ HTML_STYLESHEET =
# documentation. # documentation.
# This tag requires that the tag GENERATE_HTML is set to YES. # This tag requires that the tag GENERATE_HTML is set to YES.
HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \ HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \
doc/doxygen/css/hide_nav_sync.css \ docs/doxygen/css/hide_nav_sync.css \
doc/doxygen/css/cockatrice_docs_style.css docs/doxygen/css/cockatrice_docs_style.css
# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or # The HTML_EXTRA_FILES tag can be used to specify one or more extra images or
# other source files which should be copied to the HTML output directory. Note # other source files which should be copied to the HTML output directory. Note
@ -1466,7 +1466,7 @@ HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \
# files will be copied as-is; there are no commands or markers available. # files will be copied as-is; there are no commands or markers available.
# This tag requires that the tag GENERATE_HTML is set to YES. # This tag requires that the tag GENERATE_HTML is set to YES.
HTML_EXTRA_FILES = doc/doxygen/js/graph_toggle.js HTML_EXTRA_FILES = docs/doxygen/js/graph_toggle.js
# The HTML_COLORSTYLE tag can be used to specify if the generated HTML output # The HTML_COLORSTYLE tag can be used to specify if the generated HTML output
# should be rendered with a dark or light theme. # should be rendered with a dark or light theme.

View file

@ -84,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p
### Code ### Code
To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.<br> To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) first.<br>
We maintain two tags for contributors to easier find issues to potentially work on: 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)<br> - [![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)<br>
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on. Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on.
@ -103,7 +103,7 @@ There are various places where useful information for different needs are mainta
- [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported` - [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported`
- [Official Webpage](https://cockatrice.github.io/) - [Official Webpage](https://cockatrice.github.io/)
- [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file` - [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file`
- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) - [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md)
Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. 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. We encourage everybody to improve the documentation by suggesting edits based on this guide.
@ -163,7 +163,7 @@ The following flags (with their non-default values) can be passed to `cmake`:
| `-DTEST=1` | Enable regression tests<br> **Note:** `make test` to run tests, *googletest* will be downloaded if not available | | `-DTEST=1` | Enable regression tests<br> **Note:** `make test` to run tests, *googletest* will be downloaded if not available |
> [!TIP] > [!TIP]
> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs`: > Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs-output`:
> ```bash > ```bash
> doxygen Doxyfile > doxygen Doxyfile
> ``` > ```

View file

@ -216,6 +216,8 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_state_manager.cpp src/interface/widgets/deck_editor/deck_state_manager.cpp
src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/deck_zone_dialog.cpp
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
src/interface/widgets/game_hosting/create_game_form_widget.cpp
src/interface/widgets/game_hosting/game_creation_options.cpp
src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/background_sources.cpp
src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/background_plate_widget.cpp
src/interface/widgets/general/display/banner_widget.cpp src/interface/widgets/general/display/banner_widget.cpp
@ -291,9 +293,14 @@ set(cockatrice_SOURCES
src/interface/widgets/settings_page/storage_settings_page.cpp src/interface/widgets/settings_page/storage_settings_page.cpp
src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/settings_page/user_interface_settings_page.cpp
src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/custom_line_edit.cpp
src/interface/widgets/utility/epoch_format.h
src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/get_text_with_max.cpp
src/interface/widgets/utility/pill_painter.cpp
src/interface/widgets/utility/pill_painter.h
src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.cpp
src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/report_utils.h
src/interface/widgets/utility/table_activation.cpp
src/interface/widgets/utility/table_activation.h
src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h src/interface/widgets/utility/visibility_change_listener.h
@ -381,9 +388,12 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp
src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp
src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp
src/interface/widgets/tabs/alt_table_delegate.cpp
src/interface/widgets/tabs/report_status_delegate.cpp
src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab.cpp
src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_account.cpp
src/interface/widgets/tabs/tab_admin.cpp src/interface/widgets/tabs/tab_admin.cpp
src/interface/widgets/tabs/tab_alts.cpp
src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_card_art_rules.cpp
src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp src/interface/widgets/tabs/tab_deck_storage.cpp

View file

@ -1,15 +1,16 @@
#include "update_downloader.h" #include "update_downloader.h"
#include <QByteArray>
#include <QChar>
#include <QDir> #include <QDir>
#include <QFile> #include <QFileInfo>
#include <QIODevice> #include <QIODevice>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QSaveFile>
#include <QTemporaryDir>
#include <QUrl> #include <QUrl>
#include <QVariant> #include <QVariant>
#include <libcockatrice/network/network_manager.h> #include <libcockatrice/network/network_manager.h>
#include <utility>
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
{ {
@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl)
originalUrl = 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)); response = netMan->get(QNetworkRequest(downloadUrl));
connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished); connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished);
connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError);
connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress); 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; 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() void UpdateDownloader::fileFinished()
{ {
// If we finished but there's a redirect, follow it // An aborted, superseded reply also emits finished(). Only the reply
QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute); // currently stored in response may be consumed.
if (!redirect.isNull()) { if (response == nullptr || sender() != response) {
beginDownload(redirect.toUrl());
return; return;
} }
// Handle any errors we had // errorOccurred normally reports this first; keep the check for errors that
if (response->error()) { // only surface when the reply finishes.
emit error(response->errorString()); if (response->error() != QNetworkReply::NoError) {
downloadError(response->error());
response->deleteLater();
response = nullptr;
return; return;
} }
// Work out the file name of the download // Give the update its own private directory. The name is unpredictable, so a
QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1); // 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 const QString remoteName = QFileInfo(originalUrl.path()).fileName();
QFile file(fileName); const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName);
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) { 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; return;
} }
file.write(response->readAll()); const QByteArray data = response->readAll();
file.close(); 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 the success signal with a URL to the download file
emit downloadSuccessful(QUrl::fromLocalFile(fileName)); emit downloadSuccessful(QUrl::fromLocalFile(fileName));

View file

@ -32,10 +32,13 @@ private:
QUrl originalUrl; QUrl originalUrl;
QNetworkAccessManager *netMan; QNetworkAccessManager *netMan;
QNetworkReply *response; QNetworkReply *response;
bool errorReported = false;
bool cancelRequested = false;
private slots: private slots:
void fileFinished(); void fileFinished();
void downloadProgress(qint64 bytesRead, qint64 totalBytes); void downloadProgress(qint64 bytesRead, qint64 totalBytes);
void downloadError(QNetworkReply::NetworkError); void downloadError(QNetworkReply::NetworkError);
void cancelDownload();
}; };
#endif // COCKATRICE_UPDATEDOWNLOADER_H #endif // COCKATRICE_UPDATEDOWNLOADER_H

View file

@ -88,7 +88,7 @@ void ArrowItem::onTargetDestroyed()
// Drag and attach arrows are never inserted into the arrow registry and // Drag and attach arrows are never inserted into the arrow registry and
// have no server-side counterpart, so no deletion event can clean them // have no server-side counterpart, so no deletion event can clean them
// up. Delete them locally when either endpoint is destroyed. // up. Delete them locally when either endpoint is destroyed.
delArrow(); cancel();
return; return;
} }
@ -103,6 +103,12 @@ void ArrowItem::delArrow()
deleteLater(); deleteLater();
} }
void ArrowItem::cancel()
{
ungrabMouse();
delArrow();
}
void ArrowItem::updatePath() void ArrowItem::updatePath()
{ {
if (!targetItem) { if (!targetItem) {
@ -350,6 +356,17 @@ void ArrowDragItem::addChildArrow(ArrowDragItem *child)
childArrows.append(child); childArrows.append(child);
} }
void ArrowDragItem::cancel()
{
for (const auto &child : childArrows) {
if (child) {
child->cancel();
}
}
childArrows.clear();
ArrowItem::cancel();
}
void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{ {
if (targetLocked || !startItem) { if (targetLocked || !startItem) {
@ -393,8 +410,10 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
targetItem ? updatePath() : updatePath(endPos); targetItem ? updatePath() : updatePath(endPos);
update(); update();
for (auto *child : childArrows) { for (const auto &child : childArrows) {
child->mouseMoveEvent(event); if (child) {
child->mouseMoveEvent(event);
}
} }
} }
@ -403,10 +422,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (!startItem) { if (!startItem) {
// The source card was destroyed while the arrow was being drawn. // The source card was destroyed while the arrow was being drawn.
// Clean up the arrow and its children instead of leaking them. // Clean up the arrow and its children instead of leaking them.
delArrow(); cancel();
for (auto *child : childArrows) {
child->mouseReleaseEvent(event);
}
return; return;
} }
@ -415,7 +431,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
// For now, we can safely assume that the start item is always a card. // For now, we can safely assume that the start item is always a card.
// The target item can be a player as well. // The target item can be a player as well.
if (!startCard) { if (!startCard) {
delArrow(); cancel();
return; return;
} }
@ -435,7 +451,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) { } else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId(); arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId();
} else { } else {
delArrow(); cancel();
return; return;
} }
@ -448,9 +464,12 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
} }
delArrow(); delArrow();
for (auto *child : childArrows) { for (const auto &child : childArrows) {
child->mouseReleaseEvent(event); if (child) {
child->mouseReleaseEvent(event);
}
} }
childArrows.clear();
} }
// ArrowAttachItem // ArrowAttachItem
@ -471,6 +490,17 @@ void ArrowAttachItem::addChildArrow(ArrowAttachItem *child)
childArrows.append(child); childArrows.append(child);
} }
void ArrowAttachItem::cancel()
{
for (const auto &child : childArrows) {
if (child) {
child->cancel();
}
}
childArrows.clear();
ArrowItem::cancel();
}
void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{ {
if (targetLocked || !startItem) { if (targetLocked || !startItem) {
@ -509,8 +539,10 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
targetItem ? updatePath() : updatePath(endPos); targetItem ? updatePath() : updatePath(endPos);
update(); update();
for (auto *child : childArrows) { for (const auto &child : childArrows) {
child->mouseMoveEvent(event); if (child) {
child->mouseMoveEvent(event);
}
} }
} }
@ -519,17 +551,16 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (!startItem) { if (!startItem) {
// The source card was destroyed while the arrow was being drawn. // The source card was destroyed while the arrow was being drawn.
// Clean up the arrow and its children instead of leaking them. // Clean up the arrow and its children instead of leaking them.
delArrow(); cancel();
for (auto *child : childArrows) {
child->mouseReleaseEvent(event);
}
return; return;
} }
// Attaching could move startItem under the current cursor position, causing all children to retarget to it right // Attaching could move startItem under the current cursor position, causing all children to retarget to it right
// before they are processed. Prevent that. // before they are processed. Prevent that.
for (auto *child : childArrows) { for (const auto &child : childArrows) {
child->setTargetLocked(true); if (child) {
child->setTargetLocked(true);
}
} }
if (targetItem && targetItem != startItem) { if (targetItem && targetItem != startItem) {
@ -541,7 +572,10 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
} }
delArrow(); delArrow();
for (auto *child : childArrows) { for (const auto &child : childArrows) {
child->mouseReleaseEvent(event); if (child) {
child->mouseReleaseEvent(event);
}
} }
childArrows.clear();
} }

View file

@ -70,6 +70,8 @@ public:
void onTargetDestroyed(); void onTargetDestroyed();
void delArrow(); void delArrow();
/// Abandons an in-progress drag arrow, discarding any child arrows with it.
virtual void cancel();
void updatePath(); void updatePath();
void updatePath(const QPointF &endPoint); void updatePath(const QPointF &endPoint);
void startDrawAnimation(); void startDrawAnimation();
@ -112,12 +114,13 @@ class ArrowDragItem : public ArrowItem
private: private:
PlayerLogic *player; PlayerLogic *player;
int deleteInPhase; int deleteInPhase;
QList<ArrowDragItem *> childArrows; QList<QPointer<ArrowDragItem>> childArrows;
QMetaObject::Connection positionConnection; QMetaObject::Connection positionConnection;
public: public:
ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase); ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase);
void addChildArrow(ArrowDragItem *child); void addChildArrow(ArrowDragItem *child);
void cancel() override;
protected: protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
@ -129,12 +132,13 @@ class ArrowAttachItem : public ArrowItem
Q_OBJECT Q_OBJECT
private: private:
PlayerLogic *player; PlayerLogic *player;
QList<ArrowAttachItem *> childArrows; QList<QPointer<ArrowAttachItem>> childArrows;
QMetaObject::Connection positionConnection; QMetaObject::Connection positionConnection;
public: public:
explicit ArrowAttachItem(ArrowTarget *_startItem); explicit ArrowAttachItem(ArrowTarget *_startItem);
void addChildArrow(ArrowAttachItem *child); void addChildArrow(ArrowAttachItem *child);
void cancel() override;
protected: protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;

View file

@ -294,6 +294,13 @@ void CardItem::drawArrow(const QColor &arrowColor)
return; return;
} }
// A held shortcut repeats the action before the previous arrow is placed.
// That arrow has lost the mouse grab to the new one, so it would never
// receive the release that deletes it; discard it (and its children).
if (pendingArrow) {
pendingArrow->cancel();
}
auto *game = owner->getGame(); auto *game = owner->getGame();
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
int phase = 0; // 0 means to not set the phase int phase = 0; // 0 means to not set the phase
@ -303,6 +310,7 @@ void CardItem::drawArrow(const QColor &arrowColor)
} }
ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase); ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase);
scene()->addItem(arrow); scene()->addItem(arrow);
pendingArrow = arrow;
arrow->grabMouse(); arrow->grabMouse();
for (const auto &item : scene()->selectedItems()) { for (const auto &item : scene()->selectedItems()) {
@ -326,8 +334,14 @@ void CardItem::drawAttachArrow()
return; return;
} }
// See drawArrow(): a repeated trigger must not orphan the previous arrow.
if (pendingArrow) {
pendingArrow->cancel();
}
auto *arrow = new ArrowAttachItem(this); auto *arrow = new ArrowAttachItem(this);
scene()->addItem(arrow); scene()->addItem(arrow);
pendingArrow = arrow;
arrow->grabMouse(); arrow->grabMouse();
for (const auto &item : scene()->selectedItems()) { for (const auto &item : scene()->selectedItems()) {

View file

@ -13,11 +13,13 @@
#include <QList> #include <QList>
#include <QPoint> #include <QPoint>
#include <QPointF> #include <QPointF>
#include <QPointer>
#include <QString> #include <QString>
#include <QVariantList> #include <QVariantList>
#include <libcockatrice/game/board/card_state.h> #include <libcockatrice/game/board/card_state.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
class ArrowItem;
class CardDragItem; class CardDragItem;
class ServerInfo_Card; class ServerInfo_Card;
class PlayerLogic; class PlayerLogic;
@ -34,6 +36,7 @@ class CardItem : public AbstractCardItem
Q_OBJECT Q_OBJECT
private: private:
CardDragItem *dragItem; CardDragItem *dragItem;
QPointer<ArrowItem> pendingArrow;
void prepareDelete(); void prepareDelete();
void handleClickedToPlay(bool shiftHeld); void handleClickedToPlay(bool shiftHeld);

View file

@ -64,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
QString zoneName = zone->getName(); QString zoneName = zone->getName();
if (zoneName == ZoneNames::TABLE) { if (zoneName == ZoneNames::TABLE) {
fromStr = tr(" from play"); fromStr = tr("from play");
} else if (zoneName == ZoneNames::GRAVE) { } else if (zoneName == ZoneNames::GRAVE) {
fromStr = tr(" from their graveyard"); fromStr = tr("from their graveyard");
} else if (zoneName == ZoneNames::EXILE) { } else if (zoneName == ZoneNames::EXILE) {
fromStr = tr(" from exile"); fromStr = tr("from exile");
} else if (zoneName == ZoneNames::HAND) { } else if (zoneName == ZoneNames::HAND) {
fromStr = tr(" from their hand"); fromStr = tr("from their hand");
} else if (zoneName == ZoneNames::DECK) { } else if (zoneName == ZoneNames::DECK) {
if (position == 0) { if (position == 0) {
if (cardName.isEmpty()) { if (cardName.isEmpty()) {
@ -82,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
cardNameContainsStartZone = true; cardNameContainsStartZone = true;
} else { } else {
if (ownerChange) { if (ownerChange) {
fromStr = tr(" from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); fromStr = tr("from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
} else { } else {
fromStr = tr(" from the top of their library"); fromStr = tr("from the top of their library");
} }
} }
} else if (position == zone->getCards().size()) { } else if (position == zone->getCards().size()) {
@ -97,29 +97,34 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
cardNameContainsStartZone = true; cardNameContainsStartZone = true;
} else { } else {
if (ownerChange) { if (ownerChange) {
fromStr = tr(" from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); fromStr = tr("from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
} else { } else {
fromStr = tr(" from the bottom of their library"); fromStr = tr("from the bottom of their library");
} }
} }
} else { } else {
if (ownerChange) { if (ownerChange) {
fromStr = tr(" from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); fromStr = tr("from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
} else { } else {
fromStr = tr(" from their library"); fromStr = tr("from their library");
} }
} }
} else if (zoneName == ZoneNames::SIDEBOARD) { } else if (zoneName == ZoneNames::SIDEBOARD) {
fromStr = tr(" from sideboard"); fromStr = tr("from sideboard");
} else if (zoneName == ZoneNames::STACK) { } else if (zoneName == ZoneNames::STACK) {
fromStr = tr(" from the stack"); fromStr = tr("from the stack");
} else { } else {
fromStr = tr(" from custom zone '%1'").arg(zoneName); fromStr = tr("from custom zone '%1'").arg(zoneName);
} }
if (!cardNameContainsStartZone) { if (!cardNameContainsStartZone) {
cardName.clear(); cardName.clear();
} }
// The separator space is added here rather than stored in the tr() string so
// translators never have to preserve an invisible leading blank.
if (!fromStr.isEmpty()) {
fromStr.prepend(' ');
}
return {cardName, fromStr}; return {cardName, fromStr};
} }

View file

@ -123,7 +123,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts
aRevealToAll = new QAction(this); aRevealToAll = new QAction(this);
mCardCounters = new QMenu; mCardCounters = new QMenu(this);
// Card counters // Card counters
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 6; ++i) {
@ -225,7 +225,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
} }
addSeparator(); addSeparator();
addAction(aClone); addAction(aClone);
addMenu(new MoveMenu(player)); addMenu(new MoveMenu(player, this));
addSeparator(); addSeparator();
addAction(aAttach); addAction(aAttach);
if (card->getAttachedTo()) { if (card->getAttachedTo()) {
@ -233,7 +233,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
} }
addAction(aDrawArrow); addAction(aDrawArrow);
addSeparator(); addSeparator();
addMenu(new PtMenu(player)); addMenu(new PtMenu(player, this));
addAction(aSetAnnotation); addAction(aSetAnnotation);
addSeparator(); addSeparator();
addAction(aReduceLifeByPower); addAction(aReduceLifeByPower);
@ -275,7 +275,7 @@ void CardMenu::createStackMenu(bool canModifyCard)
addAction(aPlayFacedown); addAction(aPlayFacedown);
addSeparator(); addSeparator();
addAction(aClone); addAction(aClone);
addMenu(new MoveMenu(player)); addMenu(new MoveMenu(player, this));
addSeparator(); addSeparator();
addAction(aAttach); addAction(aAttach);
addAction(aDrawArrow); addAction(aDrawArrow);
@ -304,7 +304,7 @@ void CardMenu::createGraveyardOrExileMenu(bool canModifyCard)
addAction(aPlayFacedown); addAction(aPlayFacedown);
addSeparator(); addSeparator();
addAction(aClone); addAction(aClone);
addMenu(new MoveMenu(player)); addMenu(new MoveMenu(player, this));
addSeparator(); addSeparator();
addAction(aAttach); addAction(aAttach);
addAction(aDrawArrow); addAction(aDrawArrow);
@ -342,7 +342,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
addSeparator(); addSeparator();
addAction(aClone); addAction(aClone);
addMenu(new MoveMenu(player)); addMenu(new MoveMenu(player, this));
// actions that are really wonky when done from deck or sideboard // actions that are really wonky when done from deck or sideboard
if (card->getZone()->getName() == ZoneNames::HAND) { if (card->getZone()->getName() == ZoneNames::HAND) {
@ -366,7 +366,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
void CardMenu::createZonelessMenu(bool canModifyCard) void CardMenu::createZonelessMenu(bool canModifyCard)
{ {
if (canModifyCard) { if (canModifyCard) {
addMenu(new MoveMenu(player)); addMenu(new MoveMenu(player, this));
} }
} }
@ -415,7 +415,7 @@ void CardMenu::addRelatedCardView()
} }
addSeparator(); addSeparator();
auto viewRelatedCards = new QMenu(tr("View related cards")); auto viewRelatedCards = new QMenu(tr("View related cards"), this);
addMenu(viewRelatedCards); addMenu(viewRelatedCards);
for (const CardRelation *relatedCard : relatedCards) { for (const CardRelation *relatedCard : relatedCards) {
QString relatedCardName = relatedCard->getName(); QString relatedCardName = relatedCard->getName();
@ -463,16 +463,16 @@ void CardMenu::addRelatedCardActions()
relatedCardName = relatedCard.getName(); // "name" relatedCardName = relatedCard.getName(); // "name"
} }
QString text = tr("Token: "); QString text;
if (cardRelation->getDoesAttach()) { if (cardRelation->getDoesAttach()) {
text += text = cardRelation->getDoesTransform() ? tr("Token: Transform into \"%1\"").arg(relatedCardName)
tr(cardRelation->getDoesTransform() ? "Transform into " : "Attach to ") + "\"" + relatedCardName + "\""; : tr("Token: Attach to \"%1\"").arg(relatedCardName);
} else if (cardRelation->getIsVariable()) { } else if (cardRelation->getIsVariable()) {
text += "X " + relatedCardName; text = tr("Token: X %1").arg(relatedCardName);
} else if (cardRelation->getDefaultCount() != 1) { } else if (cardRelation->getDefaultCount() != 1) {
text += QString::number(cardRelation->getDefaultCount()) + "x " + relatedCardName; text = tr("Token: %1x %2").arg(cardRelation->getDefaultCount()).arg(relatedCardName);
} else { } else {
text += relatedCardName; text = tr("Token: %1").arg(relatedCardName);
} }
if (createRelatedCards == nullptr) { if (createRelatedCards == nullptr) {
@ -587,4 +587,4 @@ void CardMenu::setShortcutsActive()
aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i])); aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i]));
aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i])); aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i]));
} }
} }

View file

@ -6,6 +6,9 @@
#include "../player_graphics_item.h" #include "../player_graphics_item.h"
#include <QAction> #include <QAction>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QInputDialog>
#include <QKeySequence> #include <QKeySequence>
#include <QList> #include <QList>
#include <QMap> #include <QMap>
@ -67,6 +70,15 @@ HandMenu::HandMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(p
aRevealRandomHandCardToAll->setData(-1); aRevealRandomHandCardToAll->setData(-1);
connect(aRevealRandomHandCardToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardTriggered); connect(aRevealRandomHandCardToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardTriggered);
mRevealRandomHandCards = addMenu(QString());
mRevealRandomHandCards->setObjectName("revealRandomHandCardsMenu");
connect(mRevealRandomHandCards, &QMenu::aboutToShow, this,
&HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers);
aRevealRandomHandCardsToAll = new QAction(this);
aRevealRandomHandCardsToAll->setData(-1);
connect(aRevealRandomHandCardsToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered);
mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll);
// We still need to add these actions to menu here so that the shortcuts are active right away // We still need to add these actions to menu here so that the shortcuts are active right away
mRevealHand->addAction(aRevealHandToAll); mRevealHand->addAction(aRevealHandToAll);
mRevealRandomHandCard->addAction(aRevealRandomHandCardToAll); mRevealRandomHandCard->addAction(aRevealRandomHandCardToAll);
@ -146,6 +158,8 @@ void HandMenu::retranslateUi()
mRevealRandomHandCard->setTitle(tr("Reveal r&andom card to...")); mRevealRandomHandCard->setTitle(tr("Reveal r&andom card to..."));
aRevealRandomHandCardToAll->setText(tr("All players")); aRevealRandomHandCardToAll->setText(tr("All players"));
mRevealRandomHandCards->setTitle(tr("Reveal a chosen number of random cards to..."));
aRevealRandomHandCardsToAll->setText(tr("All players"));
} }
} }
@ -233,3 +247,51 @@ void HandMenu::onRevealRandomHandCardTriggered()
const int targetId = action->data().toInt(); const int targetId = action->data().toInt();
player->getLogic()->getPlayerActions()->actRevealRandomHandCard(targetId); player->getLogic()->getPlayerActions()->actRevealRandomHandCard(targetId);
} }
void HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers()
{
const bool hasCards = !player->getLogic()->getHandZone()->getCards().isEmpty();
mRevealRandomHandCards->clear();
aRevealRandomHandCardsToAll->setEnabled(hasCards);
mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll);
mRevealRandomHandCards->addSeparator();
const auto &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values();
for (auto *other : players) {
if (other == player->getLogic()) {
continue;
}
auto *action = mRevealRandomHandCards->addAction(other->getPlayerInfo()->getName());
action->setData(other->getPlayerInfo()->getId());
action->setEnabled(hasCards);
connect(action, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered);
}
}
void HandMenu::onRevealRandomHandCardsTriggered()
{
const auto *action = qobject_cast<QAction *>(sender());
const int handSize = player->getLogic()->getHandZone()->getCards().size();
if (!action || handSize == 0) {
return;
}
const int targetId = action->data().toInt();
auto *actions = player->getLogic()->getPlayerActions();
auto *dialog = new QInputDialog(dialogParent());
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->setWindowTitle(tr("Reveal Random Hand Cards"));
dialog->setLabelText(tr("Number of distinct random cards to reveal to %1:").arg(action->text()));
dialog->setInputMode(QInputDialog::IntInput);
dialog->setIntRange(1, handSize);
dialog->setIntValue(qMin(2, handSize));
connect(dialog, &QDialog::accepted, actions,
[dialog, actions, targetId] { actions->actRevealRandomHandCards(targetId, dialog->intValue()); });
dialog->open();
}
QWidget *HandMenu::dialogParent() const
{
if (auto *view = player->scene() ? player->scene()->views().value(0) : nullptr) {
return view->window();
}
return nullptr;
}

View file

@ -42,8 +42,11 @@ private slots:
void populateRevealRandomHandCardMenuWithActivePlayers(); void populateRevealRandomHandCardMenuWithActivePlayers();
void onRevealHandTriggered(); void onRevealHandTriggered();
void onRevealRandomHandCardTriggered(); void onRevealRandomHandCardTriggered();
void populateRevealRandomHandCardsMenuWithActivePlayers();
void onRevealRandomHandCardsTriggered();
private: private:
QWidget *dialogParent() const;
PlayerGraphicsItem *player; PlayerGraphicsItem *player;
QAction *aViewHand = nullptr; QAction *aViewHand = nullptr;
@ -62,6 +65,9 @@ private:
QMenu *mRevealRandomHandCard = nullptr; QMenu *mRevealRandomHandCard = nullptr;
QAction *aRevealRandomHandCardToAll = nullptr; QAction *aRevealRandomHandCardToAll = nullptr;
QMenu *mRevealRandomHandCards = nullptr;
QAction *aRevealRandomHandCardsToAll = nullptr;
QMenu *mMoveHandMenu = nullptr; QMenu *mMoveHandMenu = nullptr;
QAction *aMoveHandToTopLibrary = nullptr; QAction *aMoveHandToTopLibrary = nullptr;
QAction *aMoveHandToBottomLibrary = nullptr; QAction *aMoveHandToBottomLibrary = nullptr;

View file

@ -10,7 +10,7 @@
#include <libcockatrice/game/player/player_actions.h> #include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h> #include <libcockatrice/game/player/player_logic.h>
MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) MoveMenu::MoveMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Move to"), parent)
{ {
aMoveToTopLibrary = new QAction(this); aMoveToTopLibrary = new QAction(this);
aMoveToTopLibrary->setData(cmMoveToTopLibrary); aMoveToTopLibrary->setData(cmMoveToTopLibrary);

View file

@ -17,7 +17,7 @@ class MoveMenu : public QMenu
Q_OBJECT Q_OBJECT
public: public:
explicit MoveMenu(PlayerGraphicsItem *player); explicit MoveMenu(PlayerGraphicsItem *player, QWidget *parent);
void setShortcutsActive(); void setShortcutsActive();
void retranslateUi(); void retranslateUi();

View file

@ -23,6 +23,7 @@
#include <QMenu> #include <QMenu>
#include <QString> #include <QString>
#include <libcockatrice/game/abstract_game.h> #include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/game_meta_info.h>
#include <libcockatrice/game/player/player_info.h> #include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h> #include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h> #include <libcockatrice/game/player/player_manager.h>
@ -70,6 +71,11 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&PlayerMenu::refreshShortcuts); &PlayerMenu::refreshShortcuts);
// Re-evaluate shortcuts when the game starts or ends, so the lobby only exposes the roll-die shortcut.
connect(player->getLogic()->getGame()->getGameMetaInfo(), &GameMetaInfo::startedChanged, this,
&PlayerMenu::refreshShortcuts);
refreshShortcuts(); refreshShortcuts();
retranslateUi(); retranslateUi();
@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts()
void PlayerMenu::setShortcutsActive() void PlayerMenu::setShortcutsActive()
{ {
shortcutsActive = true; shortcutsActive = true;
if (!isGameStarted()) {
applyLobbyShortcuts();
return;
}
for (auto *c : managedComponents) { for (auto *c : managedComponents) {
c->setShortcutsActive(); c->setShortcutsActive();
} }
@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive()
for (auto *c : managedComponents) { for (auto *c : managedComponents) {
c->setShortcutsInactive(); c->setShortcutsInactive();
} }
emit shortcutsDeactivated();
}
bool PlayerMenu::isGameStarted() const
{
return player->getLogic()->getGame()->getGameMetaInfo()->started();
}
void PlayerMenu::applyLobbyShortcuts()
{
// Everything stays unbound until the game starts; only the roll-die shortcut is usable in the lobby.
for (auto *c : managedComponents) {
c->setShortcutsInactive();
}
if (utilityMenu) {
utilityMenu->setLobbyShortcutsActive();
}
emit shortcutsDeactivated(); emit shortcutsDeactivated();
} }

View file

@ -73,17 +73,24 @@ public:
return utilityMenu; return utilityMenu;
} }
/** @brief Whether this player currently holds its shortcuts and the game has started. */
[[nodiscard]] bool getShortcutsActive() const [[nodiscard]] bool getShortcutsActive() const
{ {
return shortcutsActive; return shortcutsActive && isGameStarted();
} }
/** @brief Bind keyboard shortcuts. Called when this player gains focus. */ /** @brief Bind keyboard shortcuts. Only the roll-die shortcut is bound before the game starts. */
void setShortcutsActive(); void setShortcutsActive();
/** @brief Unbind keyboard shortcuts. Called when this player loses focus. */ /** @brief Unbind keyboard shortcuts. Called when this player loses focus. */
void setShortcutsInactive(); void setShortcutsInactive();
private: private:
/** @brief True once the game has started; false while players are still in the deck-selection lobby. */
[[nodiscard]] bool isGameStarted() const;
/** @brief Unbinds everything except the shortcuts that remain usable before the game starts. */
void applyLobbyShortcuts();
PlayerGraphicsItem *player; PlayerGraphicsItem *player;
TearOffMenu *playerMenu; TearOffMenu *playerMenu;
QMenu *countersMenu; QMenu *countersMenu;

View file

@ -9,7 +9,7 @@
#include <libcockatrice/game/player/player_actions.h> #include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h> #include <libcockatrice/game/player/player_logic.h>
PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) PtMenu::PtMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Power / toughness"), parent)
{ {
PlayerActions *playerActions = player->getLogic()->getPlayerActions(); PlayerActions *playerActions = player->getLogic()->getPlayerActions();

View file

@ -18,7 +18,7 @@ class PtMenu : public QMenu
Q_OBJECT Q_OBJECT
public: public:
explicit PtMenu(PlayerGraphicsItem *player); explicit PtMenu(PlayerGraphicsItem *player, QWidget *parent);
void retranslateUi(); void retranslateUi();
void setShortcutsActive(); void setShortcutsActive();

View file

@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive()
aCreateAnotherToken->setShortcut(QKeySequence()); aCreateAnotherToken->setShortcut(QKeySequence());
aIncrementAllCardCounters->setShortcut(QKeySequence()); aIncrementAllCardCounters->setShortcut(QKeySequence());
} }
}
void UtilityMenu::setLobbyShortcutsActive()
{
if (!player->getLogic()->getPlayerInfo()->getLocalOrJudge()) {
return;
}
aRollDie->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aRollDie"));
} }

View file

@ -32,6 +32,9 @@ public slots:
public: public:
explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu); explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu);
/** @brief Binds only the shortcuts that remain usable before the game starts. */
void setLobbyShortcutsActive();
[[nodiscard]] bool createAnotherTokenActionExists() const [[nodiscard]] bool createAnotherTokenActionExists() const
{ {
return aCreateAnotherToken != nullptr; return aCreateAnotherToken != nullptr;

View file

@ -1,21 +1,27 @@
#include "view_zone.h" #include "view_zone.h"
#include "../../client/settings/cache_settings.h" #include "../../client/settings/cache_settings.h"
#include "../../interface/theme_manager.h"
#include "../board/card_drag_item.h" #include "../board/card_drag_item.h"
#include "../board/card_item.h" #include "../board/card_item.h"
#include "../card_dimensions.h" #include "../card_dimensions.h"
#include "../z_values.h"
#include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/filters/filter_string.h" #include "libcockatrice/filters/filter_string.h"
#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" #include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
#include "libcockatrice/utility/card_ref.h" #include "libcockatrice/utility/card_ref.h"
#include <QApplication>
#include <QBrush> #include <QBrush>
#include <QColor> #include <QColor>
#include <QDebug> #include <QDebug>
#include <QFont>
#include <QFontMetrics>
#include <QMessageLogger> #include <QMessageLogger>
#include <QObject> #include <QObject>
#include <QPainter> #include <QPainter>
#include <QPalette>
#include <QSharedPointer> #include <QSharedPointer>
#include <QtGlobal> #include <QtGlobal>
#include <QtMath> #include <QtMath>
@ -34,12 +40,85 @@
class QGraphicsItem; class QGraphicsItem;
namespace
{
constexpr qreal MARKER_HORIZONTAL_PADDING = 4.0;
constexpr qreal MARKER_VERTICAL_PADDING = 2.0;
constexpr qreal MARKER_CORNER_RADIUS = 4.0;
constexpr qreal MARKER_INSET = 2.0;
constexpr int MARKER_FONT_SIZE = 9;
// Vertical pitch of a card column: each card shows this much before the next
// card overlaps it. Marker placement uses it to stay on the visible strip.
constexpr qreal CARD_STACK_OFFSET = CardDimensions::HEIGHT_F / 3.0;
QFont markerFont()
{
QFont font = themeManager != nullptr ? themeManager->appFont(AppFont::CardText) : QFont();
font.setPixelSize(MARKER_FONT_SIZE);
font.setWeight(QFont::Bold);
return font;
}
} // namespace
LibraryEdgeMarker::LibraryEdgeMarker(QGraphicsItem *parent) : QGraphicsItem(parent)
{
setAcceptedMouseButtons(Qt::NoButton);
setAcceptHoverEvents(false);
}
void LibraryEdgeMarker::setLabel(const QString &newLabel, const QString &toolTip)
{
if (label == newLabel && this->toolTip() == toolTip) {
return;
}
prepareGeometryChange();
label = newLabel;
const QFontMetrics metrics(markerFont());
markerRect = QRectF(0, 0, metrics.horizontalAdvance(label) + 2 * MARKER_HORIZONTAL_PADDING,
metrics.height() + 2 * MARKER_VERTICAL_PADDING);
setToolTip(toolTip);
update();
}
QRectF LibraryEdgeMarker::boundingRect() const
{
return markerRect;
}
void LibraryEdgeMarker::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
if (label.isEmpty()) {
return;
}
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setFont(markerFont());
// ToolTipBase/ToolTipText are the palette's designed background/text pair
// for small overlays, so the chip keeps its contrast in every theme.
const QPalette palette = qApp->palette();
painter->setPen(QPen(palette.color(QPalette::Mid), 1));
painter->setBrush(palette.color(QPalette::ToolTipBase));
painter->drawRoundedRect(markerRect.adjusted(0.5, 0.5, -0.5, -0.5), MARKER_CORNER_RADIUS, MARKER_CORNER_RADIUS);
painter->setPen(palette.color(QPalette::ToolTipText));
painter->drawText(markerRect, Qt::AlignCenter, label);
painter->restore();
}
/** /**
* @param parent the parent QGraphicsWidget containing the reveal zone * @param parent the parent QGraphicsWidget containing the reveal zone
*/ */
ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent) ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent)
: SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort) : SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort)
{ {
libraryEdgeMarker = new LibraryEdgeMarker(this);
libraryEdgeMarker->setVisible(false);
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic()); auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
viewLogic->getOriginalZone()->getViews().append(viewLogic); viewLogic->getOriginalZone()->getViews().append(viewLogic);
@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards()
gridSize = positionCardsForDisplay(cardsToDisplay); gridSize = positionCardsForDisplay(cardsToDisplay);
} }
updateLibraryEdgeMarker(cardsToDisplay);
// determine bounding rect // determine bounding rect
qreal aleft = 0; qreal aleft = 0;
qreal atop = 0; qreal atop = 0;
@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
lastColumnProp = columnProp; lastColumnProp = columnProp;
qreal x = col * CardDimensions::WIDTH_F; qreal x = col * CardDimensions::WIDTH_F;
qreal y = row * CardDimensions::HEIGHT_F / 3; qreal y = row * CARD_STACK_OFFSET;
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
cItem->setRealZValue(i); cItem->setRealZValue(i);
longestRow = qMax(row, longestRow); longestRow = qMax(row, longestRow);
@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
for (int i = 0; i < cardCount; i++) { for (int i = 0; i < cardCount; i++) {
auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent()); auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent());
qreal x = (i / rows) * CardDimensions::WIDTH_F; qreal x = (i / rows) * CardDimensions::WIDTH_F;
qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3; qreal y = (i % rows) * CARD_STACK_OFFSET;
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
cItem->setRealZValue(i); cItem->setRealZValue(i);
} }
@ -270,6 +351,75 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
} }
} }
void ZoneViewZone::updateLibraryEdgeMarker(const CardList &displayedCards)
{
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (viewLogic == nullptr || !viewLogic->isLibraryEdgeView() || displayedCards.isEmpty()) {
libraryEdgeMarkerAnchorPos = QPointF();
libraryEdgeMarker->hide();
return;
}
const bool topEdge = viewLogic->showsTopOfLibrary();
CardState *edgeState = topEdge ? displayedCards.first() : displayedCards.last();
auto *edgeCard = edgeState == nullptr ? nullptr : qobject_cast<CardItem *>(edgeState->parent());
if (edgeCard == nullptr) {
libraryEdgeMarkerAnchorPos = QPointF();
libraryEdgeMarker->hide();
return;
}
libraryEdgeMarkerAnchorPos = edgeCard->pos();
applyLibraryEdgeMarkerText();
positionLibraryEdgeMarker();
// Above the hovered-card layer: the chip sits inside its card, so hovering
// that card must not let the card repaint over the indicator.
libraryEdgeMarker->setZValue(ZValues::HOVERED_CARD + 0.5);
libraryEdgeMarker->show();
}
void ZoneViewZone::positionLibraryEdgeMarker()
{
// The chip has to stay on the part of its card that is not covered by the
// next card in the column. The top card only shows its top strip, and its
// name is drawn top-left, so the chip goes to the right of that strip. The
// bottom card is fully visible, so the chip goes to its bottom-left, away
// from the bottom-right power/toughness.
const auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
const QRectF markerRect = libraryEdgeMarker->boundingRect();
const bool topEdge = viewLogic != nullptr && viewLogic->showsTopOfLibrary();
const QPointF offset = topEdge
? QPointF(CardDimensions::WIDTH_F - markerRect.width() - MARKER_INSET,
CARD_STACK_OFFSET - markerRect.height() - MARKER_INSET)
: QPointF(MARKER_INSET, CardDimensions::HEIGHT_F - markerRect.height() - MARKER_INSET);
libraryEdgeMarker->setPos(libraryEdgeMarkerAnchorPos + offset);
}
void ZoneViewZone::applyLibraryEdgeMarkerText()
{
const auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (viewLogic == nullptr) {
return;
}
const bool topEdge = viewLogic->showsTopOfLibrary();
//: Short badge on the card at the top or bottom of a viewed library. The full phrase is in the tooltip.
libraryEdgeMarker->setLabel(topEdge ? tr("Top") : tr("Bottom"),
topEdge ? tr("This card is on top of the library")
: tr("This card is at the bottom of the library"));
}
void ZoneViewZone::retranslateEdgeMarker()
{
if (libraryEdgeMarker->isVisible()) {
// The translated label can be a different width, so reposition the chip
// to keep it inside its card.
applyLibraryEdgeMarkerText();
positionLibraryEdgeMarker();
}
}
void ZoneViewZone::setFilterString(const QString &_filterString) void ZoneViewZone::setFilterString(const QString &_filterString)
{ {
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();

View file

@ -9,9 +9,11 @@
#include "select_zone.h" #include "select_zone.h"
#include <QGraphicsItem>
#include <QGraphicsLayoutItem> #include <QGraphicsLayoutItem>
#include <QList> #include <QList>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QPointF>
#include <QRectF> #include <QRectF>
#include <QSizeF> #include <QSizeF>
#include <QString> #include <QString>
@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone");
class Response; class Response;
class ServerInfo_Card; class ServerInfo_Card;
class QGraphicsSceneWheelEvent; class QGraphicsSceneWheelEvent;
class QPainter;
class QStyleOptionGraphicsItem;
class QWidget;
/**
* A small chip drawn on the card at the edge of a viewed library.
*
* It is a sibling of the card items rather than part of the zone widget, so it
* stays attached to its card when the view reflows into several columns or is
* resized. ZoneViewZone owns and positions it.
*/
class LibraryEdgeMarker : public QGraphicsItem
{
public:
explicit LibraryEdgeMarker(QGraphicsItem *parent = nullptr);
void setLabel(const QString &label, const QString &toolTip);
[[nodiscard]] QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
private:
QRectF markerRect;
QString label;
};
/** /**
* A CardZone that is a view into another CardZone. * A CardZone that is a view into another CardZone.
@ -56,6 +82,8 @@ private:
FilterString filterString = FilterString(""); FilterString filterString = FilterString("");
CardList::SortOption groupBy, sortBy; CardList::SortOption groupBy, sortBy;
bool pileView; bool pileView;
LibraryEdgeMarker *libraryEdgeMarker;
QPointF libraryEdgeMarkerAnchorPos;
struct GridSize struct GridSize
{ {
@ -64,6 +92,9 @@ private:
}; };
GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort); GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort);
void updateLibraryEdgeMarker(const CardList &displayedCards);
void applyLibraryEdgeMarkerText();
void positionLibraryEdgeMarker();
void void
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;
@ -79,6 +110,10 @@ public:
{ {
return optimumRect; return optimumRect;
} }
/** @brief Re-applies the translated library edge marker text after a language change. */
void retranslateEdgeMarker();
public slots: public slots:
void addToViews(); void addToViews();
void removeFromViews(); void removeFromViews();

View file

@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi()
shuffleCheckBox.setText(tr("shuffle when closing")); shuffleCheckBox.setText(tr("shuffle when closing"));
pileViewCheckBox.setText(tr("pile view")); pileViewCheckBox.setText(tr("pile view"));
zone->retranslateEdgeMarker();
} }
void ZoneViewWidget::stopWindowDrag() void ZoneViewWidget::stopWindowDrag()

View file

@ -1,334 +1,109 @@
#include "dlg_create_game.h" #include "dlg_create_game.h"
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
#include "../interface/widgets/tabs/tab_room.h" #include "../game_hosting/create_game_form_widget.h"
#include "../game_hosting/game_creation_options.h"
#include "../tabs/tab_room.h"
#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/room_commands.pb.h" #include "libcockatrice/protocol/pb/room_commands.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_game.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "libcockatrice/utility/macros.h" #include "libcockatrice/protocol/pending_command.h"
#include "libcockatrice/settings/game_settings.h"
#include <QCheckBox>
#include <QDebug>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel> #include <QLabel>
#include <QLineEdit>
#include <QMapIterator>
#include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QRadioButton>
#include <QSet>
#include <QSharedPointer>
#include <QSize>
#include <QSpinBox>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/game_settings.h>
#include <libcockatrice/utility/string_limits.h>
#include <qnamespace.h>
#include <string>
class QWidget;
void DlgCreateGame::sharedCtor()
{
rememberGameSettings = new QCheckBox(tr("Re&member settings"));
descriptionLabel = new QLabel(tr("&Description:"));
descriptionEdit = new QLineEdit;
descriptionEdit->setMaxLength(MAX_NAME_LENGTH);
descriptionLabel->setBuddy(descriptionEdit);
maxPlayersLabel = new QLabel(tr("P&layers:"));
maxPlayersEdit = new QSpinBox();
maxPlayersEdit->setMinimum(1);
maxPlayersEdit->setMaximum(100);
maxPlayersEdit->setValue(2);
maxPlayersLabel->setBuddy(maxPlayersEdit);
auto *generalGrid = new QGridLayout;
generalGrid->addWidget(descriptionLabel, 0, 0);
generalGrid->addWidget(descriptionEdit, 0, 1);
generalGrid->addWidget(maxPlayersLabel, 1, 0);
generalGrid->addWidget(maxPlayersEdit, 1, 1);
generalGroupBox = new QGroupBox(tr("General"));
generalGroupBox->setLayout(generalGrid);
auto *gameTypeLayout = new QVBoxLayout;
QMapIterator<int, QString> gameTypeIterator(gameTypes);
while (gameTypeIterator.hasNext()) {
gameTypeIterator.next();
auto *gameTypeRadioButton = new QRadioButton(gameTypeIterator.value(), this);
gameTypeLayout->addWidget(gameTypeRadioButton);
gameTypeCheckBoxes.insert(gameTypeIterator.key(), gameTypeRadioButton);
bool isChecked = SettingsCache::instance().game().getGameTypes().contains(gameTypeIterator.value() + ", ");
gameTypeCheckBoxes[gameTypeIterator.key()]->setChecked(isChecked);
}
auto *gameTypeGroupBox = new QGroupBox(tr("Game type"));
gameTypeGroupBox->setLayout(gameTypeLayout);
passwordLabel = new QLabel(tr("&Password:"));
passwordEdit = new QLineEdit;
passwordEdit->setMaxLength(MAX_NAME_LENGTH);
passwordLabel->setBuddy(passwordEdit);
onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join"));
onlyRegisteredCheckBox = new QCheckBox(tr("Only &registered users can join"));
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) {
onlyRegisteredCheckBox->setChecked(true);
} else {
onlyBuddiesCheckBox->setEnabled(false);
onlyRegisteredCheckBox->setEnabled(false);
}
auto *joinRestrictionsLayout = new QGridLayout;
joinRestrictionsLayout->addWidget(passwordLabel, 0, 0);
joinRestrictionsLayout->addWidget(passwordEdit, 0, 1);
joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2);
joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2);
auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions"));
joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout);
spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch"));
spectatorsAllowedCheckBox->setChecked(true);
connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &DlgCreateGame::spectatorsAllowedChanged);
spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch"));
spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat"));
spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands"));
createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator"));
auto *spectatorsLayout = new QVBoxLayout;
spectatorsLayout->addWidget(spectatorsAllowedCheckBox);
spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox);
spectatorsLayout->addWidget(spectatorsCanTalkCheckBox);
spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox);
spectatorsLayout->addWidget(createGameAsSpectatorCheckBox);
spectatorsGroupBox = new QGroupBox(tr("Spectators"));
spectatorsGroupBox->setLayout(spectatorsLayout);
startingLifeTotalLabel = new QLabel(tr("Starting life total:"));
startingLifeTotalEdit = new QSpinBox();
startingLifeTotalEdit->setMinimum(1);
startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining.
startingLifeTotalEdit->setValue(20);
startingLifeTotalLabel->setBuddy(startingLifeTotalEdit);
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, 1, 2);
gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2);
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) {
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2);
} else {
createGameAsJudgeCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setHidden(true);
}
gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"));
gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout);
auto *grid = new QGridLayout;
// Top row
grid->addWidget(generalGroupBox, 0, 0);
grid->addWidget(joinRestrictionsGroupBox, 0, 1);
// Middle row: left column
grid->addWidget(gameTypeGroupBox, 1, 0);
// Middle row: right column (game setup + spectators)
auto *rightLayout = new QVBoxLayout;
rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top
rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom
grid->addLayout(rightLayout, 1, 1);
// Bottom row
grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject);
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(grid);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
setFixedHeight(sizeHint().height());
}
DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameTypes, QWidget *parent) DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameTypes, QWidget *parent)
: QDialog(parent), room(_room), gameTypes(_gameTypes) : QDialog(parent), room(_room)
{ {
sharedCtor(); const bool registered = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered);
const bool judge = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsJudge);
rememberGameSettings->setChecked(SettingsCache::instance().game().getRememberGameSettings()); form = new CreateGameFormWidget(_gameTypes, registered, judge, this);
descriptionEdit->setText(SettingsCache::instance().game().getGameDescription()); const bool rememberSettings = SettingsCache::instance().game().getRememberGameSettings();
maxPlayersEdit->setValue(SettingsCache::instance().game().getMaxPlayers()); form->setRememberSettingsChecked(rememberSettings);
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { if (rememberSettings) {
onlyBuddiesCheckBox->setChecked(SettingsCache::instance().game().getOnlyBuddies()); form->setOptions(GameCreationOptions::fromSettings(SettingsCache::instance().game(), _gameTypes, registered));
onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered());
} else { } else {
onlyBuddiesCheckBox->setEnabled(false); form->resetToDefaults();
onlyRegisteredCheckBox->setEnabled(false);
} }
spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsAllowed()); form->focusDescription();
spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsNeedPassword());
spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanTalk());
spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanSeeEverything());
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()) { errorLabel = new QLabel(this);
actReset(); errorLabel->setObjectName("createGameErrorLabel");
} errorLabel->setWordWrap(true);
errorLabel->setVisible(false);
descriptionEdit->setFocus(); buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this);
clearButton = new QPushButton(tr("&Clear")); connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject);
clearButton = new QPushButton(tr("&Clear"), this);
buttonBox->addButton(QDialogButtonBox::Cancel); buttonBox->addButton(QDialogButtonBox::Cancel);
buttonBox->addButton(clearButton, QDialogButtonBox::ActionRole); buttonBox->addButton(clearButton, QDialogButtonBox::ActionRole);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::actOK); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::actOK);
connect(clearButton, &QPushButton::clicked, this, &DlgCreateGame::actReset); connect(clearButton, &QPushButton::clicked, this, &DlgCreateGame::actReset);
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(form);
mainLayout->addWidget(errorLabel);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
setWindowTitle(tr("Create game")); setWindowTitle(tr("Create game"));
} }
DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QString> &_gameTypes, QWidget *parent) DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QString> &_gameTypes, QWidget *parent)
: QDialog(parent), room(0), gameTypes(_gameTypes) : QDialog(parent), room(nullptr)
{ {
sharedCtor(); form = new CreateGameFormWidget(_gameTypes, false, false, this);
form->setOptions(GameCreationOptions::fromGameInfo(gameInfo, _gameTypes));
rememberGameSettings->setEnabled(false); form->setReadOnly(true);
descriptionEdit->setEnabled(false);
maxPlayersEdit->setEnabled(false);
passwordEdit->setEnabled(false);
onlyBuddiesCheckBox->setEnabled(false);
onlyRegisteredCheckBox->setEnabled(false);
spectatorsAllowedCheckBox->setEnabled(false);
spectatorsNeedPasswordCheckBox->setEnabled(false);
spectatorsCanTalkCheckBox->setEnabled(false);
spectatorsSeeEverythingCheckBox->setEnabled(false);
createGameAsSpectatorCheckBox->setEnabled(false);
startingLifeTotalEdit->setEnabled(false);
shareDecklistsOnLoadCheckBox->setEnabled(false);
shufflePlayersCheckBox->setEnabled(false);
descriptionEdit->setText(QString::fromStdString(gameInfo.description()));
maxPlayersEdit->setValue(gameInfo.max_players());
onlyBuddiesCheckBox->setChecked(gameInfo.only_buddies());
onlyRegisteredCheckBox->setChecked(gameInfo.only_registered());
spectatorsAllowedCheckBox->setChecked(gameInfo.spectators_allowed());
spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password());
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players());
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
types.insert(gameInfo.game_types(i));
}
QMapIterator<int, QString> gameTypeIterator(gameTypes);
while (gameTypeIterator.hasNext()) {
gameTypeIterator.next();
QRadioButton *gameTypeCheckBox = gameTypeCheckBoxes.value(gameTypeIterator.key());
gameTypeCheckBox->setEnabled(false);
gameTypeCheckBox->setChecked(types.contains(gameTypeIterator.key()));
}
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::accept); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::accept);
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(form);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
setWindowTitle(tr("Game information")); setWindowTitle(tr("Game information"));
} }
void DlgCreateGame::actReset() void DlgCreateGame::actReset()
{ {
descriptionEdit->setText(""); form->resetToDefaults();
maxPlayersEdit->setValue(2); clearError();
passwordEdit->setText("");
onlyBuddiesCheckBox->setChecked(false);
onlyRegisteredCheckBox->setChecked(room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered);
spectatorsAllowedCheckBox->setChecked(true);
spectatorsNeedPasswordCheckBox->setChecked(false);
spectatorsCanTalkCheckBox->setChecked(false);
spectatorsSeeEverythingCheckBox->setChecked(false);
createGameAsSpectatorCheckBox->setChecked(false);
startingLifeTotalEdit->setValue(20);
shareDecklistsOnLoadCheckBox->setChecked(false);
shufflePlayersCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setChecked(false);
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
while (gameTypeCheckBoxIterator.hasNext()) {
gameTypeCheckBoxIterator.next();
// must set auto enclusive to false to be able to set the check to false
gameTypeCheckBoxIterator.value()->setAutoExclusive(false);
gameTypeCheckBoxIterator.value()->setChecked(false);
gameTypeCheckBoxIterator.value()->setAutoExclusive(true);
}
descriptionEdit->setFocus();
} }
void DlgCreateGame::actOK() void DlgCreateGame::actOK()
{ {
Command_CreateGame cmd; clearError();
cmd.set_description(descriptionEdit->text().simplified().toStdString());
cmd.set_password(passwordEdit->text().toStdString());
cmd.set_max_players(maxPlayersEdit->value());
cmd.set_only_buddies(onlyBuddiesCheckBox->isChecked());
cmd.set_only_registered(onlyRegisteredCheckBox->isChecked());
cmd.set_spectators_allowed(spectatorsAllowedCheckBox->isChecked());
cmd.set_spectators_need_password(spectatorsNeedPasswordCheckBox->isChecked());
cmd.set_spectators_can_talk(spectatorsCanTalkCheckBox->isChecked());
cmd.set_spectators_see_everything(spectatorsSeeEverythingCheckBox->isChecked());
cmd.set_join_as_judge(createGameAsJudgeCheckBox->isChecked());
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(); const GameCreationOptions options = form->options();
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes); Command_CreateGame cmd = options.toCreateGameCommand();
while (gameTypeCheckBoxIterator.hasNext()) {
gameTypeCheckBoxIterator.next(); const bool rememberSettings = form->isRememberSettingsChecked();
if (gameTypeCheckBoxIterator.value()->isChecked()) { auto &gameSettings = SettingsCache::instance().game();
cmd.add_game_type_ids(gameTypeCheckBoxIterator.key()); gameSettings.setRememberGameSettings(rememberSettings);
_gameTypes += gameTypeCheckBoxIterator.value()->text() + ", "; if (rememberSettings) {
} gameSettings.setGameDescription(options.description);
gameSettings.setMaxPlayers(options.maxPlayers);
gameSettings.setOnlyBuddies(options.onlyBuddies);
gameSettings.setOnlyRegistered(options.onlyRegistered);
gameSettings.setSpectatorsAllowed(options.spectatorsAllowed);
gameSettings.setSpectatorsNeedPassword(options.spectatorsNeedPassword);
gameSettings.setSpectatorsCanTalk(options.spectatorsCanTalk);
gameSettings.setSpectatorsCanSeeEverything(options.spectatorsSeeEverything);
gameSettings.setCreateGameAsSpectator(options.joinAsSpectator);
gameSettings.setDefaultStartingLifeTotal(options.startingLifeTotal);
gameSettings.setShareDecklistsOnLoad(options.shareDecklistsOnLoad);
gameSettings.setShufflePlayers(options.shufflePlayers);
gameSettings.setGameTypes(form->selectedGameTypesSettingString());
} }
SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked());
if (rememberGameSettings->isChecked()) {
SettingsCache::instance().game().setGameDescription(descriptionEdit->text());
SettingsCache::instance().game().setMaxPlayers(maxPlayersEdit->value());
SettingsCache::instance().game().setOnlyBuddies(onlyBuddiesCheckBox->isChecked());
SettingsCache::instance().game().setOnlyRegistered(onlyRegisteredCheckBox->isChecked());
SettingsCache::instance().game().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked());
SettingsCache::instance().game().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked());
SettingsCache::instance().game().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked());
SettingsCache::instance().game().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked());
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); PendingCommand *pend = room->prepareRoomCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse); connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse);
room->sendRoomCommand(pend); room->sendRoomCommand(pend);
@ -343,14 +118,26 @@ void DlgCreateGame::checkResponse(const Response &response)
if (response.response_code() == Response::RespOk) { if (response.response_code() == Response::RespOk) {
accept(); accept();
} else { } else {
QMessageBox::critical(this, tr("Error"), tr("Server error.")); showError(tr("Server error."));
return;
} }
} }
void DlgCreateGame::spectatorsAllowedChanged(QT_STATE_CHANGED_T state) void DlgCreateGame::showError(const QString &message)
{ {
spectatorsNeedPasswordCheckBox->setEnabled(state); if (!errorLabel) {
spectatorsCanTalkCheckBox->setEnabled(state); return;
spectatorsSeeEverythingCheckBox->setEnabled(state); }
errorLabel->setText(message);
errorLabel->setVisible(true);
adjustSize();
}
void DlgCreateGame::clearError()
{
if (!errorLabel) {
return;
}
errorLabel->clear();
errorLabel->setVisible(false);
adjustSize();
} }

View file

@ -2,7 +2,6 @@
* @file dlg_create_game.h * @file dlg_create_game.h
* @ingroup RoomDialogs * @ingroup RoomDialogs
*/ */
//! \todo Document this file.
#ifndef DLG_CREATEGAME_H #ifndef DLG_CREATEGAME_H
#define DLG_CREATEGAME_H #define DLG_CREATEGAME_H
@ -10,21 +9,15 @@
#include <QDialog> #include <QDialog>
#include <QMap> #include <QMap>
#include <QString> #include <QString>
#include <libcockatrice/utility/macros.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
class QCheckBox; class CreateGameFormWidget;
class QDialogButtonBox; class QDialogButtonBox;
class QGroupBox;
class QLabel; class QLabel;
class QLineEdit;
class QPushButton; class QPushButton;
class QRadioButton;
class QSpinBox;
class Response; class Response;
class ServerInfo_Game; class ServerInfo_Game;
class TabRoom; class TabRoom;
class QWidget;
class DlgCreateGame : public QDialog class DlgCreateGame : public QDialog
{ {
@ -36,27 +29,16 @@ private slots:
void actOK(); void actOK();
void actReset(); void actReset();
void checkResponse(const Response &response); void checkResponse(const Response &response);
void spectatorsAllowedChanged(QT_STATE_CHANGED_T state);
private: private:
TabRoom *room; TabRoom *room;
QMap<int, QString> gameTypes; CreateGameFormWidget *form = nullptr;
QMap<int, QRadioButton *> gameTypeCheckBoxes; QDialogButtonBox *buttonBox = nullptr;
QPushButton *clearButton = nullptr;
QLabel *errorLabel = nullptr;
QGroupBox *generalGroupBox, *spectatorsGroupBox, *gameSetupOptionsGroupBox; void showError(const QString &message);
QLabel *descriptionLabel, *passwordLabel, *maxPlayersLabel, *startingLifeTotalLabel, *shareDecklistsOnLoadLabel; void clearError();
QLineEdit *descriptionEdit, *passwordEdit;
QSpinBox *maxPlayersEdit, *startingLifeTotalEdit;
QCheckBox *onlyBuddiesCheckBox, *onlyRegisteredCheckBox;
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
QCheckBox *shareDecklistsOnLoadCheckBox;
QCheckBox *shufflePlayersCheckBox;
QDialogButtonBox *buttonBox;
QPushButton *clearButton;
QCheckBox *rememberGameSettings;
void sharedCtor();
}; };
#endif #endif // DLG_CREATEGAME_H

View file

@ -56,6 +56,7 @@ DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent
playernameEdit->setMaxLength(MAX_NAME_LENGTH); playernameEdit->setMaxLength(MAX_NAME_LENGTH);
playernameLabel->setBuddy(playernameEdit); playernameLabel->setBuddy(playernameEdit);
//: Code mailed to the player so they can reset their password. Not a game token.
tokenLabel = new QLabel(tr("Token:")); tokenLabel = new QLabel(tr("Token:"));
tokenEdit = new QLineEdit(); tokenEdit = new QLineEdit();
tokenEdit->setMaxLength(MAX_NAME_LENGTH); tokenEdit->setMaxLength(MAX_NAME_LENGTH);

View file

@ -230,7 +230,8 @@ void DlgMyReports::onSelectionChanged()
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
client->sendCommand(pend); client->sendCommand(pend);
QString status = table->item(row, COL_STATUS)->text(); // The cell text is the translated label; the raw status lives in UserRole.
const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString();
bool canComment = (status == "open" || status == "assigned"); bool canComment = (status == "open" || status == "assigned");
commentButton->setEnabled(canComment); commentButton->setEnabled(canComment);
commentInput->setEnabled(canComment); commentInput->setEnabled(canComment);

View file

@ -0,0 +1,260 @@
#include "create_game_form_widget.h"
#include <QCheckBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QRadioButton>
#include <QSpinBox>
#include <QVBoxLayout>
#include <libcockatrice/utility/string_limits.h>
#include <qnamespace.h>
CreateGameFormWidget::CreateGameFormWidget(const QMap<int, QString> &gameTypes,
bool _registeredUser,
bool judge,
QWidget *parent)
: QWidget(parent), registeredUser(_registeredUser)
{
setObjectName("createGameForm");
rememberGameSettings = new QCheckBox(tr("Re&member settings"));
auto *descriptionLabel = new QLabel(tr("&Description:"));
descriptionEdit = new QLineEdit;
descriptionEdit->setMaxLength(MAX_NAME_LENGTH);
descriptionLabel->setBuddy(descriptionEdit);
auto *maxPlayersLabel = new QLabel(tr("P&layers:"));
maxPlayersEdit = new QSpinBox();
maxPlayersEdit->setMinimum(1);
maxPlayersEdit->setMaximum(100);
maxPlayersEdit->setValue(2);
maxPlayersLabel->setBuddy(maxPlayersEdit);
auto *generalGrid = new QGridLayout;
generalGrid->addWidget(descriptionLabel, 0, 0);
generalGrid->addWidget(descriptionEdit, 0, 1);
generalGrid->addWidget(maxPlayersLabel, 1, 0);
generalGrid->addWidget(maxPlayersEdit, 1, 1);
auto *generalGroupBox = new QGroupBox(tr("General"));
generalGroupBox->setLayout(generalGrid);
auto *gameTypeLayout = new QVBoxLayout;
for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) {
auto *gameTypeRadioButton = new QRadioButton(it.value(), this);
gameTypeLayout->addWidget(gameTypeRadioButton);
gameTypeCheckBoxes.insert(it.key(), gameTypeRadioButton);
}
auto *gameTypeGroupBox = new QGroupBox(tr("Game type"));
gameTypeGroupBox->setLayout(gameTypeLayout);
auto *passwordLabel = new QLabel(tr("&Password:"));
passwordEdit = new QLineEdit;
passwordEdit->setMaxLength(MAX_NAME_LENGTH);
passwordLabel->setBuddy(passwordEdit);
onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join"));
onlyRegisteredCheckBox = new QCheckBox(tr("Only &registered users can join"));
if (registeredUser) {
onlyRegisteredCheckBox->setChecked(true);
} else {
onlyBuddiesCheckBox->setEnabled(false);
onlyRegisteredCheckBox->setEnabled(false);
}
auto *joinRestrictionsLayout = new QGridLayout;
joinRestrictionsLayout->addWidget(passwordLabel, 0, 0);
joinRestrictionsLayout->addWidget(passwordEdit, 0, 1);
joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2);
joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2);
auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions"));
joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout);
spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch"));
spectatorsAllowedCheckBox->setChecked(true);
connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&CreateGameFormWidget::spectatorsAllowedChanged);
spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch"));
spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat"));
spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands"));
createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator"));
auto *spectatorsLayout = new QVBoxLayout;
spectatorsLayout->addWidget(spectatorsAllowedCheckBox);
spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox);
spectatorsLayout->addWidget(spectatorsCanTalkCheckBox);
spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox);
spectatorsLayout->addWidget(createGameAsSpectatorCheckBox);
auto *spectatorsGroupBox = new QGroupBox(tr("Spectators"));
spectatorsGroupBox->setLayout(spectatorsLayout);
auto *startingLifeTotalLabel = new QLabel(tr("Starting life total:"));
startingLifeTotalEdit = new QSpinBox();
startingLifeTotalEdit->setMinimum(1);
startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining.
startingLifeTotalEdit->setValue(20);
startingLifeTotalLabel->setBuddy(startingLifeTotalEdit);
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, 1, 2);
gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2);
if (judge) {
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2);
} else {
createGameAsJudgeCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setHidden(true);
}
auto *gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"));
gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout);
auto *grid = new QGridLayout;
// Top row
grid->addWidget(generalGroupBox, 0, 0);
grid->addWidget(joinRestrictionsGroupBox, 0, 1);
// Middle row: left column
grid->addWidget(gameTypeGroupBox, 1, 0);
// Middle row: right column (game setup + spectators)
auto *rightLayout = new QVBoxLayout;
rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top
rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom
grid->addLayout(rightLayout, 1, 1);
// Bottom row
grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed
setLayout(grid);
}
void CreateGameFormWidget::setOptions(const GameCreationOptions &options)
{
descriptionEdit->setText(options.description);
maxPlayersEdit->setValue(options.maxPlayers);
passwordEdit->setText(options.password);
onlyBuddiesCheckBox->setChecked(options.onlyBuddies);
onlyRegisteredCheckBox->setChecked(options.onlyRegistered);
spectatorsAllowedCheckBox->setChecked(options.spectatorsAllowed);
spectatorsNeedPasswordCheckBox->setChecked(options.spectatorsNeedPassword);
spectatorsCanTalkCheckBox->setChecked(options.spectatorsCanTalk);
spectatorsSeeEverythingCheckBox->setChecked(options.spectatorsSeeEverything);
createGameAsJudgeCheckBox->setChecked(options.joinAsJudge);
createGameAsSpectatorCheckBox->setChecked(options.joinAsSpectator);
startingLifeTotalEdit->setValue(options.startingLifeTotal);
shareDecklistsOnLoadCheckBox->setChecked(options.shareDecklistsOnLoad);
shufflePlayersCheckBox->setChecked(options.shufflePlayers);
for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) {
it.value()->setChecked(options.gameTypeIds.contains(it.key()));
}
}
GameCreationOptions CreateGameFormWidget::options() const
{
GameCreationOptions options;
options.description = descriptionEdit->text();
options.password = passwordEdit->text();
options.maxPlayers = maxPlayersEdit->value();
options.onlyBuddies = onlyBuddiesCheckBox->isChecked();
options.onlyRegistered = onlyRegisteredCheckBox->isChecked();
options.spectatorsAllowed = spectatorsAllowedCheckBox->isChecked();
options.spectatorsNeedPassword = spectatorsNeedPasswordCheckBox->isChecked();
options.spectatorsCanTalk = spectatorsCanTalkCheckBox->isChecked();
options.spectatorsSeeEverything = spectatorsSeeEverythingCheckBox->isChecked();
options.joinAsJudge = createGameAsJudgeCheckBox->isChecked();
options.joinAsSpectator = createGameAsSpectatorCheckBox->isChecked();
options.startingLifeTotal = startingLifeTotalEdit->value();
options.shareDecklistsOnLoad = shareDecklistsOnLoadCheckBox->isChecked();
options.shufflePlayers = shufflePlayersCheckBox->isChecked();
for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) {
if (it.value()->isChecked()) {
options.gameTypeIds.append(it.key());
}
}
return options;
}
QString CreateGameFormWidget::selectedGameTypesSettingString() const
{
QString gameTypes;
for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) {
if (it.value()->isChecked()) {
gameTypes += it.value()->text() + ", ";
}
}
return gameTypes;
}
void CreateGameFormWidget::resetToDefaults()
{
GameCreationOptions defaults;
defaults.onlyRegistered = registeredUser;
setOptions(defaults);
// An exclusive button group refuses to uncheck its selected button, which setOptions
// cannot do, so clear the exclusive flag while resetting the game type selection.
for (auto *gameTypeCheckBox : gameTypeCheckBoxes) {
gameTypeCheckBox->setAutoExclusive(false);
gameTypeCheckBox->setChecked(false);
gameTypeCheckBox->setAutoExclusive(true);
}
descriptionEdit->setFocus();
}
void CreateGameFormWidget::setReadOnly(bool readOnly)
{
const bool editable = !readOnly;
rememberGameSettings->setEnabled(editable);
descriptionEdit->setEnabled(editable);
maxPlayersEdit->setEnabled(editable);
passwordEdit->setEnabled(editable);
onlyBuddiesCheckBox->setEnabled(editable && registeredUser);
onlyRegisteredCheckBox->setEnabled(editable && registeredUser);
spectatorsAllowedCheckBox->setEnabled(editable);
spectatorsNeedPasswordCheckBox->setEnabled(editable);
spectatorsCanTalkCheckBox->setEnabled(editable);
spectatorsSeeEverythingCheckBox->setEnabled(editable);
createGameAsSpectatorCheckBox->setEnabled(editable);
startingLifeTotalEdit->setEnabled(editable);
shareDecklistsOnLoadCheckBox->setEnabled(editable);
shufflePlayersCheckBox->setEnabled(editable);
for (auto *gameTypeCheckBox : gameTypeCheckBoxes) {
gameTypeCheckBox->setEnabled(editable);
}
}
void CreateGameFormWidget::focusDescription()
{
descriptionEdit->setFocus();
}
bool CreateGameFormWidget::isRememberSettingsChecked() const
{
return rememberGameSettings->isChecked();
}
void CreateGameFormWidget::setRememberSettingsChecked(bool checked)
{
rememberGameSettings->setChecked(checked);
}
void CreateGameFormWidget::spectatorsAllowedChanged(QT_STATE_CHANGED_T state)
{
spectatorsNeedPasswordCheckBox->setEnabled(state);
spectatorsCanTalkCheckBox->setEnabled(state);
spectatorsSeeEverythingCheckBox->setEnabled(state);
}

View file

@ -0,0 +1,69 @@
/**
* @file create_game_form_widget.h
* @ingroup RoomWidgets
*/
#ifndef CREATE_GAME_FORM_WIDGET_H
#define CREATE_GAME_FORM_WIDGET_H
#include "game_creation_options.h"
#include <QMap>
#include <QWidget>
#include <libcockatrice/utility/macros.h>
#include <qtmetamacros.h>
class QCheckBox;
class QLineEdit;
class QRadioButton;
class QSpinBox;
/**
* @brief The editable create-game form, independent of settings, the network, and the room.
*
* The dialog loads @ref GameCreationOptions into it and reads them back; a future host
* activity can do the same without duplicating the widget layout.
*/
class CreateGameFormWidget : public QWidget
{
Q_OBJECT
public:
CreateGameFormWidget(const QMap<int, QString> &gameTypes,
bool registeredUser,
bool judge,
QWidget *parent = nullptr);
void setOptions(const GameCreationOptions &options);
[[nodiscard]] GameCreationOptions options() const;
[[nodiscard]] QString selectedGameTypesSettingString() const;
void resetToDefaults();
void setReadOnly(bool readOnly);
void focusDescription();
[[nodiscard]] bool isRememberSettingsChecked() const;
void setRememberSettingsChecked(bool checked);
private slots:
void spectatorsAllowedChanged(QT_STATE_CHANGED_T state);
private:
bool registeredUser;
QMap<int, QRadioButton *> gameTypeCheckBoxes;
QLineEdit *descriptionEdit;
QLineEdit *passwordEdit;
QSpinBox *maxPlayersEdit;
QSpinBox *startingLifeTotalEdit;
QCheckBox *onlyBuddiesCheckBox;
QCheckBox *onlyRegisteredCheckBox;
QCheckBox *spectatorsAllowedCheckBox;
QCheckBox *spectatorsNeedPasswordCheckBox;
QCheckBox *spectatorsCanTalkCheckBox;
QCheckBox *spectatorsSeeEverythingCheckBox;
QCheckBox *createGameAsJudgeCheckBox;
QCheckBox *createGameAsSpectatorCheckBox;
QCheckBox *shareDecklistsOnLoadCheckBox;
QCheckBox *shufflePlayersCheckBox;
QCheckBox *rememberGameSettings;
};
#endif // CREATE_GAME_FORM_WIDGET_H

View file

@ -0,0 +1,88 @@
#include "game_creation_options.h"
#include <QSet>
#include <libcockatrice/interfaces/interface_game_settings_provider.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
GameCreationOptions GameCreationOptions::fromSettings(const IGameSettingsProvider &settings,
const QMap<int, QString> &gameTypes,
bool registeredUser)
{
GameCreationOptions options;
options.description = settings.getGameDescription();
options.maxPlayers = settings.getMaxPlayers();
if (registeredUser) {
options.onlyBuddies = settings.getOnlyBuddies();
options.onlyRegistered = settings.getOnlyRegistered();
}
options.spectatorsAllowed = settings.getSpectatorsAllowed();
options.spectatorsNeedPassword = settings.getSpectatorsNeedPassword();
options.spectatorsCanTalk = settings.getSpectatorsCanTalk();
options.spectatorsSeeEverything = settings.getSpectatorsCanSeeEverything();
options.joinAsSpectator = settings.getCreateGameAsSpectator();
options.startingLifeTotal = settings.getDefaultStartingLifeTotal();
options.shareDecklistsOnLoad = settings.getShareDecklistsOnLoad();
options.shufflePlayers = settings.getShufflePlayers();
// The old dialog stored game type names as "Name, " in one string; keep the exact
// substring test so an existing game.ini selects the same types after the split.
const QString savedGameTypes = settings.getGameTypes();
for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) {
if (savedGameTypes.contains(it.value() + ", ")) {
options.gameTypeIds.append(it.key());
}
}
return options;
}
GameCreationOptions GameCreationOptions::fromGameInfo(const ServerInfo_Game &gameInfo,
const QMap<int, QString> &gameTypes)
{
GameCreationOptions options;
options.description = QString::fromStdString(gameInfo.description());
options.maxPlayers = gameInfo.max_players();
options.onlyBuddies = gameInfo.only_buddies();
options.onlyRegistered = gameInfo.only_registered();
options.spectatorsAllowed = gameInfo.spectators_allowed();
options.spectatorsNeedPassword = gameInfo.spectators_need_password();
options.spectatorsCanTalk = gameInfo.spectators_can_chat();
options.spectatorsSeeEverything = gameInfo.spectators_omniscient();
options.shareDecklistsOnLoad = gameInfo.share_decklists_on_load();
options.shufflePlayers = gameInfo.shuffle_players();
QSet<int> selectedTypes;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
selectedTypes.insert(gameInfo.game_types(i));
}
for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) {
if (selectedTypes.contains(it.key())) {
options.gameTypeIds.append(it.key());
}
}
return options;
}
Command_CreateGame GameCreationOptions::toCreateGameCommand() const
{
Command_CreateGame cmd;
cmd.set_description(description.simplified().toStdString());
cmd.set_password(password.toStdString());
cmd.set_max_players(maxPlayers);
cmd.set_only_buddies(onlyBuddies);
cmd.set_only_registered(onlyRegistered);
cmd.set_spectators_allowed(spectatorsAllowed);
cmd.set_spectators_need_password(spectatorsNeedPassword);
cmd.set_spectators_can_talk(spectatorsCanTalk);
cmd.set_spectators_see_everything(spectatorsSeeEverything);
cmd.set_join_as_judge(joinAsJudge);
cmd.set_join_as_spectator(joinAsSpectator);
cmd.set_starting_life_total(startingLifeTotal);
cmd.set_share_decklists_on_load(shareDecklistsOnLoad);
cmd.set_shuffle_players(shufflePlayers);
for (int gameTypeId : gameTypeIds) {
cmd.add_game_type_ids(gameTypeId);
}
return cmd;
}

View file

@ -0,0 +1,64 @@
/**
* @file game_creation_options.h
* @ingroup RoomWidgets
*/
#ifndef GAME_CREATION_OPTIONS_H
#define GAME_CREATION_OPTIONS_H
#include "libcockatrice/protocol/pb/room_commands.pb.h"
#include <QList>
#include <QMap>
#include <QString>
class IGameSettingsProvider;
class ServerInfo_Game;
/**
* @brief The values needed to create a game, independent of the widgets that edit them.
*
* Pure value object: it holds no widget or settings dependencies, so every host entry
* point can build a @ref Command_CreateGame through the same conversion.
*/
struct GameCreationOptions
{
QString description;
QString password;
int maxPlayers = 2;
bool onlyBuddies = false;
bool onlyRegistered = false;
bool spectatorsAllowed = true;
bool spectatorsNeedPassword = false;
bool spectatorsCanTalk = false;
bool spectatorsSeeEverything = false;
bool joinAsJudge = false;
bool joinAsSpectator = false;
int startingLifeTotal = 20;
bool shareDecklistsOnLoad = false;
bool shufflePlayers = false;
QList<int> gameTypeIds;
/**
* @brief Reads the remembered create-game settings.
*
* Join restrictions are only meaningful for registered users, so they are left
* unset for an unregistered host. Game type ids are resolved from the room's map
* by matching the remembered names.
*/
[[nodiscard]] static GameCreationOptions
fromSettings(const IGameSettingsProvider &settings, const QMap<int, QString> &gameTypes, bool registeredUser);
/**
* @brief Reads the read-only fields shown in the game information view.
*/
[[nodiscard]] static GameCreationOptions fromGameInfo(const ServerInfo_Game &gameInfo,
const QMap<int, QString> &gameTypes);
/**
* @brief Builds the room command, simplifying the description's whitespace.
*/
[[nodiscard]] Command_CreateGame toCreateGameCommand() const;
};
#endif

View file

@ -21,12 +21,14 @@
#include <QCheckBox> #include <QCheckBox>
#include <QDebug> #include <QDebug>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHideEvent>
#include <QList> #include <QList>
#include <QMap> #include <QMap>
#include <QScrollArea> #include <QScrollArea>
#include <QScrollBar> #include <QScrollBar>
#include <QSharedPointer> #include <QSharedPointer>
#include <QSizePolicy> #include <QSizePolicy>
#include <QShowEvent>
#include <QString> #include <QString>
#include <QTimer> #include <QTimer>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck
setLayout(layout); setLayout(layout);
widgetLoadingBufferTimer = new QTimer(this); widgetLoadingBufferTimer = new QTimer(this);
deckChangeTimer = new QTimer(this);
deckChangeTimer->setSingleShot(true);
connect(deckChangeTimer, &QTimer::timeout, this, &PrintingSelector::updateDisplay);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi()
void PrintingSelector::printingsInDeckChanged() void PrintingSelector::printingsInDeckChanged()
{ {
if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) {
// Delay the update to avoid race conditions displayDirty = true;
QTimer::singleShot(100, this, &PrintingSelector::updateDisplay); if (isVisible()) {
// Coalesce deck changes while the model settles.
deckChangeTimer->start(100);
} else {
widgetLoadingBufferTimer->stop();
}
} }
} }
void PrintingSelector::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
if (displayDirty) {
updateDisplay();
}
}
void PrintingSelector::hideEvent(QHideEvent *event)
{
// A partially built batch must be rebuilt on reveal, not resumed with
// stale printing data after the selected card or deck changes.
if (widgetLoadingBufferTimer->isActive() || deckChangeTimer->isActive()) {
widgetLoadingBufferTimer->stop();
deckChangeTimer->stop();
displayDirty = true;
}
QWidget::hideEvent(event);
}
/** /**
* @return A map of uuid to amounts (main, side, tokens). * @return A map of uuid to amounts (main, side, tokens).
*/ */
@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts()
*/ */
void PrintingSelector::updateDisplay() void PrintingSelector::updateDisplay()
{ {
deckChangeTimer->stop();
displayDirty = false;
widgetLoadingBufferTimer->stop(); widgetLoadingBufferTimer->stop();
widgetLoadingBufferTimer->deleteLater(); widgetLoadingBufferTimer->deleteLater();
widgetLoadingBufferTimer = new QTimer(this); widgetLoadingBufferTimer = new QTimer(this);
@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay()
*/ */
void PrintingSelector::setCard(const CardInfoPtr &newCard) void PrintingSelector::setCard(const CardInfoPtr &newCard)
{ {
if (newCard.isNull()) { // Same-name cards may be different database snapshots after a reload.
selectedCard = newCard; if (selectedCard == newCard) {
updateDisplay();
return;
}
// we don't need to redraw the widget if the card is the same
if (!selectedCard.isNull() && selectedCard->getName() == newCard->getName()) {
return; return;
} }
widgetLoadingBufferTimer->stop();
deckChangeTimer->stop();
selectedCard = newCard; selectedCard = newCard;
if (isVisible()) { if (isVisible()) {
updateDisplay(); updateDisplay();
} else {
displayDirty = true;
} }
flowWidget->setMinimumSizeToMaxSizeHint(); flowWidget->setMinimumSizeToMaxSizeHint();
flowWidget->scrollArea->verticalScrollBar()->setValue(0); flowWidget->scrollArea->verticalScrollBar()->setValue(0);

View file

@ -18,6 +18,8 @@ class QCheckBox;
class QHBoxLayout; class QHBoxLayout;
class QString; class QString;
class QTimer; class QTimer;
class QShowEvent;
class QHideEvent;
class QVBoxLayout; class QVBoxLayout;
class SettingsButtonWidget; class SettingsButtonWidget;
template <class Key, class T> class QMap; template <class Key, class T> class QMap;
@ -47,6 +49,10 @@ public:
void setCard(const CardInfoPtr &newCard); void setCard(const CardInfoPtr &newCard);
void getAllSetsForCurrentCard(); void getAllSetsForCurrentCard();
protected:
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
public slots: public slots:
void retranslateUi(); void retranslateUi();
void updateDisplay(); void updateDisplay();
@ -88,6 +94,8 @@ private:
DeckStateManager *deckStateManager; DeckStateManager *deckStateManager;
CardInfoPtr selectedCard; CardInfoPtr selectedCard;
QTimer *widgetLoadingBufferTimer; QTimer *widgetLoadingBufferTimer;
QTimer *deckChangeTimer;
bool displayDirty = false;
int currentIndex = 0; int currentIndex = 0;
}; };

View file

@ -266,9 +266,13 @@ void GameSelector::actCreate()
return; return;
} }
DlgCreateGame dlg(room, room->getGameTypes(), this); // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the
dlg.exec(); // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated
updateTitle(); // child would then be deleted by Qt and crash.
auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle);
dlg->exec();
} }
void GameSelector::checkResponse(const Response &response) 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 ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
const QMap<int, QString> &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id()); const QMap<int, QString> &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id());
DlgCreateGame dlg(gameInfo, gameTypes, this); auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this);
dlg.exec(); dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
}); });
QAction copyLink(tr("Cop&y game link")); QAction copyLink(tr("Cop&y game link"));

View file

@ -83,6 +83,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aDemoteFromDeveloper = new QAction(QString(), this); aDemoteFromDeveloper = new QAction(QString(), this);
aGetAdminNotes = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this);
aInvestigateUser = new QAction(QString(), this); aInvestigateUser = new QAction(QString(), this);
aInvestigateAlts = new QAction(QString(), this);
retranslateUi(); retranslateUi();
} }
@ -111,6 +112,7 @@ void UserContextMenu::retranslateUi()
aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aDemoteFromDeveloper->setText(tr("Demote user from de&veloper"));
aGetAdminNotes->setText(tr("View admin notes")); aGetAdminNotes->setText(tr("View admin notes"));
aInvestigateUser->setText(tr("Investigate user")); aInvestigateUser->setText(tr("Investigate user"));
aInvestigateAlts->setText(tr("Investigate &alts"));
} }
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
@ -454,6 +456,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addSeparator(); menu->addSeparator();
menu->addAction(aGetAdminNotes); menu->addAction(aGetAdminNotes);
menu->addAction(aInvestigateUser); menu->addAction(aInvestigateUser);
menu->addAction(aInvestigateAlts);
menu->addSeparator(); menu->addSeparator();
if (userLevel.testFlag(ServerInfo_User::IsModerator) && if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
@ -499,6 +502,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aBanHistory->setEnabled(anotherUser); aBanHistory->setEnabled(anotherUser);
aGetAdminNotes->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser);
aInvestigateUser->setEnabled(anotherUser); aInvestigateUser->setEnabled(anotherUser);
aInvestigateAlts->setEnabled(anotherUser);
aPromoteToMod->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser);
aDemoteFromMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser);
aPromoteToJudge->setEnabled(anotherUser); aPromoteToJudge->setEnabled(anotherUser);
@ -553,6 +557,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execAdminNotes(userName); execAdminNotes(userName);
} else if (actionClicked == aInvestigateUser) { } else if (actionClicked == aInvestigateUser) {
execInvestigateUser(userName); execInvestigateUser(userName);
} else if (actionClicked == aInvestigateAlts) {
execInvestigateAlts(userName);
} else if (actionClicked == aCopyToClipBoard) { } else if (actionClicked == aCopyToClipBoard) {
QClipboard *clipboard = QGuiApplication::clipboard(); QClipboard *clipboard = QGuiApplication::clipboard();
clipboard->setText(deckHash); clipboard->setText(deckHash);
@ -745,6 +751,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName)
tabSupervisor->openTabModeration(userName); tabSupervisor->openTabModeration(userName);
} }
void UserContextMenu::execInvestigateAlts(const QString &userName)
{
tabSupervisor->openTabAlts(userName);
}
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
{ {
Command_AdjustMod cmd; Command_AdjustMod cmd;

View file

@ -54,6 +54,7 @@ private:
QAction *aGetAdminNotes; QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider; std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
QAction *aInvestigateUser; QAction *aInvestigateUser;
QAction *aInvestigateAlts;
signals: signals:
void openMessageDialog(const QString &userName, bool focus); void openMessageDialog(const QString &userName, bool focus);
private slots: private slots:
@ -127,6 +128,7 @@ public:
void execWarnHistory(const QString &userName); void execWarnHistory(const QString &userName);
void execAdminNotes(const QString &userName); void execAdminNotes(const QString &userName);
void execInvestigateUser(const QString &userName); void execInvestigateUser(const QString &userName);
void execInvestigateAlts(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge); void execAdjustJudge(const QString &userName, bool shouldBeJudge);
void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper);

View file

@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
}); });
homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600); homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600);
//: Unit following a number of seconds. Keep the leading space unless your language writes number and unit flush.
homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds")); homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds"));
homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency()); homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency());
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(), connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
@ -675,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi()
} }
handGroupBox->setTitle(tr("Hand layout")); handGroupBox->setTitle(tr("Hand layout"));
horizontalHandCheckBox.setText(tr("Display hand horizontally (wastes space)")); horizontalHandCheckBox.setText(tr("Display hand horizontally (requires more space)"));
leftJustifiedHandCheckBox.setText(tr("Enable left justification")); leftJustifiedHandCheckBox.setText(tr("Enable left justification"));
tableGroupBox->setTitle(tr("Table grid layout")); tableGroupBox->setTitle(tr("Table grid layout"));

View file

@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage()
auto *chatGrid = new QGridLayout; auto *chatGrid = new QGridLayout;
chatGrid->addWidget(&chatMentionCheckBox, 0, 0); chatGrid->addWidget(&chatMentionCheckBox, 0, 0);
chatGrid->addWidget(&invertMentionForeground, 0, 1); chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight);
chatGrid->addWidget(mentionColor, 0, 2); chatGrid->addWidget(mentionColor, 0, 2);
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0); chatGrid->addWidget(&invertMentionForeground, 0, 3);
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0); chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1);
chatGrid->addWidget(&hexLabel, 1, 2); chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1);
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0); chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1);
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0); chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1);
chatGrid->addWidget(&messagePopups, 5, 0); chatGrid->addWidget(&messagePopups, 5, 0, 1, -1);
chatGrid->addWidget(&mentionPopups, 6, 0); chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1);
chatGrid->addWidget(&roomHistory, 7, 0); chatGrid->addWidget(&roomHistory, 7, 0, 1, -1);
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1);
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0); chatGrid->addWidget(&useGameTimeCheckBox, 9, 0, 1, -1);
// Expand the first column more than the following ones
chatGrid->setColumnStretch(0, 3);
chatGrid->setColumnStretch(1, 1);
chatGroupBox = new QGroupBox; chatGroupBox = new QGroupBox;
chatGroupBox->setLayout(chatGrid); chatGroupBox->setLayout(chatGrid);
@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor); connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor);
auto *highlightNotice = new QGridLayout; auto *highlightNotice = new QGridLayout;
highlightNotice->addWidget(highlightColor, 0, 2);
highlightNotice->addWidget(&invertHighlightForeground, 0, 1);
highlightNotice->addWidget(&hexHighlightLabel, 1, 2);
highlightNotice->addWidget(customAlertString, 0, 0); highlightNotice->addWidget(customAlertString, 0, 0);
highlightNotice->addWidget(&customAlertStringLabel, 1, 0); highlightNotice->addWidget(&highlightColorStringLabel, 0, 1, Qt::AlignRight);
highlightNotice->addWidget(highlightColor, 0, 2);
highlightNotice->addWidget(&invertHighlightForeground, 0, 3);
highlightNotice->addWidget(&customAlertStringLabel, 1, 0, 1, -1);
// Expand the first column more than the following ones
highlightNotice->setColumnStretch(0, 3);
highlightNotice->setColumnStretch(1, 1);
customAlertStringLabel.setBuddy(customAlertString); customAlertStringLabel.setBuddy(customAlertString);
highlightGroupBox = new QGroupBox; highlightGroupBox = new QGroupBox;
highlightGroupBox->setLayout(highlightNotice); highlightGroupBox->setLayout(highlightNotice);
@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage()
auto *channelGrid = new QGridLayout; auto *channelGrid = new QGridLayout;
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0); channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
channelGrid->addWidget(channelUnreadColor, 0, 1); channelGrid->addWidget(channelUnreadColor, 0, 1);
channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2);
channelGrid->addWidget(&channelMentionColorLabel, 1, 0); channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
channelGrid->addWidget(channelMentionColor, 1, 1); channelGrid->addWidget(channelMentionColor, 1, 1);
channelGrid->addWidget(&hexChannelMentionLabel, 1, 2);
channelGroupBox = new QGroupBox; channelGroupBox = new QGroupBox;
channelGroupBox->setLayout(channelGrid); channelGroupBox->setLayout(channelGrid);
@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi()
channelGroupBox->setTitle(tr("Room channel tabs")); channelGroupBox->setTitle(tr("Room channel tabs"));
channelUnreadColorLabel.setText(tr("Unread messages color")); channelUnreadColorLabel.setText(tr("Unread messages color"));
channelMentionColorLabel.setText(tr("Mentioned messages color")); channelMentionColorLabel.setText(tr("Mentioned messages color"));
channelUnreadColor->setPlaceholderText(tr("Enter HEX code"));
channelMentionColor->setPlaceholderText(tr("Enter HEX code"));
chatMentionCheckBox.setText(tr("Enable chat mentions")); chatMentionCheckBox.setText(tr("Enable chat mentions"));
chatMentionCompleterCheckbox.setText(tr("Enable mention completer")); chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
messageGroupBox->setTitle(tr("In-game message macros")); messageGroupBox->setTitle(tr("In-game message macros"));
@ -341,15 +347,15 @@ void MessagesSettingsPage::retranslateUi()
ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users")); ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users"));
invertMentionForeground.setText(tr("Invert text color")); invertMentionForeground.setText(tr("Invert text color"));
invertHighlightForeground.setText(tr("Invert text color")); invertHighlightForeground.setText(tr("Invert text color"));
mentionColorStringLabel.setText(tr("Color"));
highlightColorStringLabel.setText(tr("Color"));
mentionColor->setPlaceholderText(tr("Enter HEX code"));
highlightColor->setPlaceholderText(tr("Enter HEX code"));
messagePopups.setText(tr("Enable desktop notifications for private messages")); messagePopups.setText(tr("Enable desktop notifications for private messages"));
mentionPopups.setText(tr("Enable desktop notification for mentions")); mentionPopups.setText(tr("Enable desktop notification for mentions"));
roomHistory.setText(tr("Enable room message history on join")); roomHistory.setText(tr("Enable room message history on join"));
ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages")); ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs")); 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")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3")); customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
aAdd->setText(tr("Add New Message")); aAdd->setText(tr("Add New Message"));

View file

@ -58,13 +58,11 @@ private:
QLineEdit *channelUnreadColor; QLineEdit *channelUnreadColor;
QLineEdit *channelMentionColor; QLineEdit *channelMentionColor;
QLineEdit *customAlertString; QLineEdit *customAlertString;
QLabel hexLabel;
QLabel hexHighlightLabel;
QLabel channelUnreadColorLabel; QLabel channelUnreadColorLabel;
QLabel channelMentionColorLabel; QLabel channelMentionColorLabel;
QLabel hexChannelUnreadLabel;
QLabel hexChannelMentionLabel;
QLabel customAlertStringLabel; QLabel customAlertStringLabel;
QLabel mentionColorStringLabel;
QLabel highlightColorStringLabel;
QLabel explainMessagesLabel; QLabel explainMessagesLabel;
void storeSettings(); void storeSettings();

View file

@ -0,0 +1,109 @@
#include "alt_table_delegate.h"
#include "../utility/pill_painter.h"
#include <QApplication>
#include <QModelIndex>
#include <QPainter>
#include <QPalette>
#include <QStyle>
#include <QStyleOptionViewItem>
namespace
{
constexpr int ICON_TEXT_GAP = 4;
} // namespace
AltTableDelegate::AltTableDelegate(int viaColumn,
int warnsColumn,
int bansColumn,
int sharedColumn,
int activeColumn,
QObject *parent)
: QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn),
sharedColumn(sharedColumn), activeColumn(activeColumn)
{
}
void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const bool selected = (option.state & QStyle::State_Selected) != 0;
QStyleOptionViewItem opt(option);
initStyleOption(&opt, index);
painter->setFont(opt.font);
QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style();
const QString text = index.data(Qt::DisplayRole).toString();
const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText;
if (index.column() == viaColumn) {
bool sharedOk = false;
const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk);
const bool ipMatch = text.startsWith(QStringLiteral("ip:"));
// shared_count for an IP match already includes the investigated account,
// so anything above 2 means the address is shared beyond that pair.
const bool dimmed = sharedOk && ipMatch && shared > 2;
const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole;
QPalette::ColorRole tintRole = QPalette::NoRole;
if (text == QStringLiteral("email")) {
tintRole = QPalette::Link;
} else if (text.startsWith(QStringLiteral("clientid:"))) {
tintRole = QPalette::Highlight;
} else if (ipMatch) {
tintRole = QPalette::PlaceholderText;
}
if (tintRole != QPalette::NoRole) {
if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) {
QStyledItemDelegate::paint(painter, opt, index);
}
return;
}
QStyledItemDelegate::paint(painter, opt, index);
return;
}
if (index.column() == warnsColumn || index.column() == bansColumn) {
bool countOk = false;
const int count = text.toInt(&countOk);
if (countOk && count > 0 && !selected) {
const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical
: QStyle::SP_MessageBoxWarning);
const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP);
QRect textRect = opt.rect;
if (!icon.isNull() && iconSize > 0) {
const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize,
iconSize);
icon.paint(painter, iconRect);
textRect.setLeft(iconRect.right() + ICON_TEXT_GAP);
}
const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width());
style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided,
emphasisRole);
return;
}
}
if (index.column() == activeColumn) {
const bool active = index.data(Qt::UserRole).toBool();
const QPalette::ColorRole tintRole = active ? QPalette::Highlight : QPalette::PlaceholderText;
if (!pill_painter::paint(painter, opt, tintRole, emphasisRole, text)) {
QStyledItemDelegate::paint(painter, opt, index);
}
return;
}
QStyledItemDelegate::paint(painter, opt, index);
}
QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const QSize hint = QStyledItemDelegate::sizeHint(option, index);
if (index.column() == viaColumn || index.column() == activeColumn) {
return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y);
}
if (index.column() == warnsColumn || index.column() == bansColumn) {
return hint + QSize(2 * ICON_TEXT_GAP, 0);
}
return hint;
}

View file

@ -0,0 +1,37 @@
#ifndef ALT_TABLE_DELEGATE_H
#define ALT_TABLE_DELEGATE_H
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
/**
* Badge + icon painting for the User Alts table. The match-evidence column
* renders as a palette-tinted pill per match type (email / client id / IP), and
* an IP shared by more than one other account is dimmed. Warn/ban counts gain
* the style's warning/critical icons when nonzero. The active column renders as
* a palette-tinted pill (Highlight when active, PlaceholderText when inactive,
* from Qt::UserRole). Selected rows and unknown content fall back to default
* painting.
*/
class AltTableDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit AltTableDelegate(int viaColumn,
int warnsColumn,
int bansColumn,
int sharedColumn,
int activeColumn,
QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
private:
int viaColumn;
int warnsColumn;
int bansColumn;
int sharedColumn;
int activeColumn;
};
#endif // ALT_TABLE_DELEGATE_H

View file

@ -38,7 +38,8 @@ public:
[[nodiscard]] QString getTabText() const override [[nodiscard]] QString getTabText() const override
{ {
auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName(); auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName();
return tr("EDHRec: ") + cardName; //: EDHRec is a website name, keep it as written. %1 is the card being looked up.
return tr("EDHRec: %1").arg(cardName);
} }
CardSizeWidget *getCardSizeSlider() const CardSizeWidget *getCardSizeSlider() const

View file

@ -0,0 +1,54 @@
#include "report_status_delegate.h"
#include "../utility/pill_painter.h"
#include <QApplication>
#include <QPainter>
#include <QPalette>
#include <QStyle>
#include <QStyleOptionViewItem>
ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent)
{
}
void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const bool selected = (option.state & QStyle::State_Selected) != 0;
QString status = index.data(Qt::UserRole).toString();
if (status.isEmpty()) {
status = index.data(Qt::DisplayRole).toString();
}
QPalette::ColorRole tintRole = QPalette::NoRole;
if (status == QStringLiteral("open")) {
tintRole = QPalette::Highlight;
} else if (status == QStringLiteral("assigned")) {
tintRole = QPalette::Link;
} else if (status == QStringLiteral("resolved") || status == QStringLiteral("dismissed")) {
tintRole = QPalette::PlaceholderText;
}
QStyleOptionViewItem opt(option);
initStyleOption(&opt, index);
painter->setFont(opt.font);
if (tintRole == QPalette::NoRole) {
const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::PlaceholderText;
QStyle *fallbackStyle = opt.widget != nullptr ? opt.widget->style() : QApplication::style();
fallbackStyle->drawItemText(painter, opt.rect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, opt.text,
textRole);
return;
}
const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText;
if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) {
QStyledItemDelegate::paint(painter, opt, index);
}
}
QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const QSize hint = QStyledItemDelegate::sizeHint(option, index);
return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y);
}

View file

@ -0,0 +1,22 @@
#ifndef REPORT_STATUS_DELEGATE_H
#define REPORT_STATUS_DELEGATE_H
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
/**
* Status pill for the report queue table. Paints the status text on a
* translucent badge tinted with a palette role per status (open, assigned,
* resolved, dismissed), so the colors always follow the active theme.
* Falls back to default painting for selected rows and unknown statuses.
*/
class ReportStatusDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit ReportStatusDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
};
#endif // REPORT_STATUS_DELEGATE_H

View file

@ -0,0 +1,699 @@
#include "tab_alts.h"
#include "../utility/epoch_format.h"
#include "../utility/table_activation.h"
#include "abstract_client.h"
#include "alt_table_delegate.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/response_user_alts.pb.h"
#include <QAbstractItemView>
#include <QDateTime>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSpinBox>
#include <QSplitter>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextEdit>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/response_warn_history.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <string>
namespace
{
constexpr int COL_ALTS_USER = 0;
constexpr int COL_ALTS_VIA = 1;
constexpr int COL_ALTS_DEPTH = 2;
constexpr int COL_ALTS_SHARED = 3;
constexpr int COL_ALTS_IPS = 4;
constexpr int COL_ALTS_EMAIL = 5;
constexpr int COL_ALTS_CLIENTID = 6;
constexpr int COL_ALTS_REGISTERED = 7;
constexpr int COL_ALTS_LAST_LOGIN = 8;
constexpr int COL_ALTS_WARNS = 9;
constexpr int COL_ALTS_BANS = 10;
constexpr int COL_ALTS_ACTIVE = 11;
constexpr int COL_ALTS_COUNT = 12;
constexpr int COL_WARNS_TIME = 0;
constexpr int COL_WARNS_MODERATOR = 1;
constexpr int COL_WARNS_REASON = 2;
constexpr int COL_WARNS_COUNT = 3;
constexpr int COL_BANS_TIME = 0;
constexpr int COL_BANS_MODERATOR = 1;
constexpr int COL_BANS_LENGTH = 2;
constexpr int COL_BANS_REASON = 3;
constexpr int COL_BANS_COUNT = 4;
constexpr int MAX_ALT_DEPTH = 3;
// Starting default only (adjustable 1-100000): shared VPN exits routinely
// cover dozens of users, so 10 hides the obvious VPN noise but keeps
// small shared networks visible.
constexpr int DEFAULT_VPN_THRESHOLD = 10;
void configureAltTable(QTableWidget *table)
{
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
table->horizontalHeader()->setStretchLastSection(true);
}
} // namespace
TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser)
: Tab(_tabSupervisor), client(_client)
{
auto *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
connect(searchEdit, &QLineEdit::returnPressed, this, &TabAlts::investigateUser);
depthSpin = new QSpinBox;
depthSpin->setMinimum(1);
depthSpin->setMaximum(MAX_ALT_DEPTH);
depthSpin->setValue(1);
depthSpin->setKeyboardTracking(false);
connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged);
investigateButton = new QPushButton;
connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser);
backButton = new QPushButton;
backButton->setEnabled(false);
connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack);
statusLabel = new QLabel;
trailLabel = new QLabel;
trailLabel->setTextFormat(Qt::RichText);
trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
trailLabel->setOpenExternalLinks(false);
// The trail grows with every hop, so let it wrap instead of forcing the tab
// wider than the window.
trailLabel->setWordWrap(true);
connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) {
if (!userName.isEmpty() && userName != currentUser) {
goToTrailEntry(userName);
}
});
auto *topBar = new QHBoxLayout;
topBar->addWidget(backButton);
topBar->addWidget(searchEdit);
topBar->addWidget(depthSpin);
topBar->addWidget(investigateButton);
auto makeChip = [this](const QString &text) {
auto *chip = new QPushButton(text);
chip->setCheckable(true);
chip->setChecked(true);
connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged);
return chip;
};
emailChip = makeChip(QString());
clientIdChip = makeChip(QString());
ipChip = makeChip(QString());
vpnLabel = new QLabel;
vpnSpin = new QSpinBox;
vpnSpin->setMinimum(1);
vpnSpin->setMaximum(100000);
vpnSpin->setValue(DEFAULT_VPN_THRESHOLD);
connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged);
windowLabel = new QLabel;
monthsSpin = new QSpinBox;
monthsSpin->setMinimum(1);
monthsSpin->setMaximum(24);
monthsSpin->setValue(6);
monthsSpin->setKeyboardTracking(false);
connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged);
auto *filterBar = new QHBoxLayout;
filterBar->addWidget(emailChip);
filterBar->addWidget(clientIdChip);
filterBar->addWidget(ipChip);
filterBar->addWidget(vpnLabel);
filterBar->addWidget(vpnSpin);
filterBar->addWidget(windowLabel);
filterBar->addWidget(monthsSpin);
filterBar->addStretch();
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
configureAltTable(altsTable);
altsDelegate =
new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, COL_ALTS_ACTIVE, altsTable);
altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate);
altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate);
altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate);
altsTable->setItemDelegateForColumn(COL_ALTS_ACTIVE, altsDelegate);
connectTableActivation(altsTable, this, [this](const QModelIndex &index) {
if (!index.isValid()) {
return;
}
auto *userItem = altsTable->item(index.row(), COL_ALTS_USER);
if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) {
investigate(userItem->text());
}
});
connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected);
detailNameValue = new QLabel;
detailCountsValue = new QLabel;
detailNotesEdit = new QTextEdit;
detailNotesEdit->setReadOnly(true);
// Compact cap so the warn/ban tables stay visible without scrolling the tab.
detailNotesEdit->setMaximumHeight(60);
warnsTable = new QTableWidget(0, COL_WARNS_COUNT);
configureAltTable(warnsTable);
bansTable = new QTableWidget(0, COL_BANS_COUNT);
configureAltTable(bansTable);
auto *detailGrid = new QGridLayout;
detailNameLabel = new QLabel;
detailCountsLabel = new QLabel;
detailNotesLabel = new QLabel;
detailGrid->addWidget(detailNameLabel, 0, 0);
detailGrid->addWidget(detailNameValue, 0, 1);
detailGrid->addWidget(detailCountsLabel, 0, 2);
detailGrid->addWidget(detailCountsValue, 0, 3);
detailGrid->addWidget(detailNotesLabel, 1, 0);
detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3);
detailsGroup = new QGroupBox;
auto *detailsLayout = new QVBoxLayout(detailsGroup);
detailsLayout->addLayout(detailGrid);
detailsLayout->addWidget(warnsTable, 1);
detailsLayout->addWidget(bansTable, 1);
investigateSelectedButton = new QPushButton;
investigateSelectedButton->setEnabled(false);
connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected);
detailsLayout->addWidget(investigateSelectedButton);
auto *contentSplitter = new QSplitter(Qt::Horizontal);
contentSplitter->addWidget(altsTable);
contentSplitter->addWidget(detailsGroup);
contentSplitter->setStretchFactor(0, 3);
contentSplitter->setStretchFactor(1, 2);
detailsGroup->setMinimumWidth(260);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
mainLayout->addLayout(filterBar);
mainLayout->addWidget(statusLabel);
mainLayout->addWidget(trailLabel);
mainLayout->addWidget(contentSplitter, 1);
retranslateUi();
clearDetails();
investigate(initialUser);
}
void TabAlts::retranslateUi()
{
searchEdit->setPlaceholderText(tr("User name"));
depthSpin->setPrefix(tr("Depth: "));
depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts."));
investigateButton->setText(tr("Investigate"));
backButton->setText(tr("Back"));
backButton->setToolTip(tr("Back to the previously investigated user"));
if (currentUser.isEmpty()) {
statusLabel->setText(tr("Enter a user name to investigate."));
}
emailChip->setText(tr("Email"));
emailChip->setToolTip(tr("Show or hide email matches"));
clientIdChip->setText(tr("Client ID"));
clientIdChip->setToolTip(tr("Show or hide client ID matches"));
ipChip->setText(tr("IP"));
ipChip->setToolTip(tr("Show or hide IP address matches"));
vpnLabel->setText(tr("Hide IPs shared by more than"));
vpnSpin->setSuffix(tr(" users"));
windowLabel->setText(tr("Session window"));
monthsSpin->setSuffix(tr(" months"));
monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered."));
altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"),
tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"),
tr("Active")});
altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account"));
warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")});
bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")});
detailsGroup->setTitle(tr("Selected user"));
detailNameLabel->setText(tr("Name:"));
detailCountsLabel->setText(tr("Counts:"));
detailNotesLabel->setText(tr("Admin notes:"));
investigateSelectedButton->setText(tr("Investigate this user"));
investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation"));
if (!lastAlts.isEmpty()) {
renderTable();
}
}
QString TabAlts::formatEpoch(quint64 ts) const
{
return formatEpochTimestamp(ts, tr("Unknown"));
}
void TabAlts::investigate(const QString &userName)
{
if (userName.isEmpty() || userName == currentUser) {
return;
}
// Alt matches are symmetric, so revisiting an earlier account is a normal
// double-click. Truncate there instead of pushing it again, so the trail
// stays a single path.
if (backStack.contains(userName)) {
goToTrailEntry(userName);
return;
}
if (!currentUser.isEmpty()) {
backStack << currentUser;
backButton->setEnabled(true);
}
loadUser(userName);
}
void TabAlts::goToTrailEntry(const QString &userName)
{
const int index = backStack.indexOf(userName);
if (index < 0) {
investigate(userName);
return;
}
// Clicking an earlier crumb truncates the history there instead of pushing
// another entry, so the trail keeps a single path and Back still works.
backStack.erase(backStack.begin() + index, backStack.end());
backButton->setEnabled(!backStack.isEmpty());
loadUser(userName);
}
void TabAlts::goBack()
{
if (backStack.isEmpty()) {
return;
}
const QString previousUser = backStack.takeLast();
backButton->setEnabled(!backStack.isEmpty());
loadUser(previousUser);
}
void TabAlts::loadUser(const QString &userName)
{
currentUser = userName;
searchEdit->setText(userName);
updateTrail();
investigateUser();
}
void TabAlts::updateTrail()
{
QStringList trail = backStack;
trail << currentUser;
trail.removeAll(QString());
QStringList links;
for (const QString &name : trail) {
const QString safeName = name.toHtmlEscaped();
if (name == currentUser) {
links << QStringLiteral("<b>") + safeName + QStringLiteral("</b>");
} else {
links << QStringLiteral("<a href=\"") + safeName + QStringLiteral("\">") + safeName +
QStringLiteral("</a>");
}
}
trailLabel->setText(links.join(QStringLiteral(" › ")));
trailLabel->setVisible(!links.isEmpty());
}
void TabAlts::investigateUser()
{
const QString userName = searchEdit->text().simplified();
if (userName.isEmpty()) {
return;
}
if (userName != currentUser && !currentUser.isEmpty()) {
backStack << currentUser;
backButton->setEnabled(true);
}
currentUser = userName;
updateTrail();
reloadCurrentUser();
}
void TabAlts::reloadCurrentUser()
{
lastAlts.clear();
selectedUser.clear();
clearDetails();
altsTable->setRowCount(0);
statusLabel->setText(tr("Loading alts for %1...").arg(currentUser));
requestAlts(currentUser, depthSpin->value());
}
void TabAlts::requestAlts(const QString &userName, int depth)
{
Command_GetUserAlts cmd;
cmd.set_user_name(userName.toStdString());
cmd.set_depth(static_cast<google::protobuf::uint32>(depth));
cmd.set_include_email(emailChip->isChecked());
cmd.set_include_clientid(clientIdChip->isChecked());
cmd.set_include_ip(ipChip->isChecked());
cmd.set_session_months(static_cast<google::protobuf::uint32>(monthsSpin->value()));
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == currentUser) {
altsResponse(response);
}
});
client->sendCommand(pend);
}
void TabAlts::altsResponse(const Response &response)
{
lastAlts.clear();
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser));
return;
}
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
// The server answers RespOk with no rows when the user does not exist.
if (resp.alts_size() == 0) {
statusLabel->setText(tr("User %1 not found.").arg(currentUser));
return;
}
for (int i = 0; i < resp.alts_size(); ++i) {
lastAlts << resp.alts(i);
}
renderTable();
}
bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const
{
const QString name = QString::fromStdString(alt.user_name());
if (name == currentUser) {
return true;
}
if (!alt.has_match_reason() || alt.match_reason().empty()) {
return true;
}
const QString reason = QString::fromStdString(alt.match_reason());
if (reason == QStringLiteral("email")) {
return emailChip->isChecked();
}
if (reason.startsWith(QStringLiteral("clientid:"))) {
return clientIdChip->isChecked();
}
if (reason.startsWith(QStringLiteral("ip:"))) {
return ipChip->isChecked() &&
(!alt.has_shared_count() || alt.shared_count() <= static_cast<unsigned>(vpnSpin->value()));
}
return true;
}
void TabAlts::renderTable()
{
// Blocked so the programmatic refill neither wipes the details panel
// (via the selection-cleared path) nor refetches them on restore.
const QSignalBlocker blocker(altsTable);
altsTable->setRowCount(0);
int emailCount = 0;
int clientIdCount = 0;
int ipCount = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
const QString reason = QString::fromStdString(alt.match_reason());
if (reason == QStringLiteral("email")) {
++emailCount;
} else if (reason.startsWith(QStringLiteral("clientid:"))) {
++clientIdCount;
} else if (reason.startsWith(QStringLiteral("ip:"))) {
++ipCount;
}
}
// The server omits unchecked match types, so keep the last count while a
// chip is off instead of relabelling it "(0)".
if (emailChip->isChecked()) {
emailChip->setText(tr("Email (%1)").arg(emailCount));
}
if (clientIdChip->isChecked()) {
clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount));
}
if (ipChip->isChecked()) {
ipChip->setText(tr("IP (%1)").arg(ipCount));
}
QFont userFont = altsTable->font();
userFont.setBold(true);
const QColor dimColor = palette().color(QPalette::Disabled, QPalette::Text);
int hidden = 0;
int row = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
if (!rowVisible(alt)) {
++hidden;
continue;
}
altsTable->insertRow(row);
auto *userItem = new QTableWidgetItem(QString::fromStdString(alt.user_name()));
userItem->setFont(userFont);
altsTable->setItem(row, COL_ALTS_USER, userItem);
altsTable->setItem(row, COL_ALTS_VIA,
new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty()
? QString::fromStdString(alt.match_reason())
: QStringLiteral("-")));
altsTable->setItem(row, COL_ALTS_DEPTH,
new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-")));
altsTable->setItem(
row, COL_ALTS_SHARED,
new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-")));
altsTable->setItem(
row, COL_ALTS_IPS,
new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-")));
altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login())));
altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count())));
altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count())));
auto *activeItem = new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"));
activeItem->setData(Qt::UserRole, alt.is_active());
altsTable->setItem(row, COL_ALTS_ACTIVE, activeItem);
// shared_count already includes the investigated account, so anything
// above 2 means the address is shared beyond that pair.
if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() &&
alt.shared_count() > 2) {
for (int col = 0; col < COL_ALTS_COUNT; ++col) {
if (col == COL_ALTS_VIA) {
continue;
}
if (auto *item = altsTable->item(row, col)) {
item->setForeground(dimColor);
}
}
}
++row;
}
// The server appends the frontier user as a depth-0 seed row; it is the
// investigated user, not one of their related accounts.
int relatedCount = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
if (!alt.has_depth() || alt.depth() > 0) {
++relatedCount;
}
}
if (hidden == 0) {
statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser));
} else {
statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.")
.arg(row)
.arg(hidden)
.arg(relatedCount)
.arg(currentUser));
}
if (!selectedUser.isEmpty()) {
bool restored = false;
for (int restoreRow = 0; restoreRow < altsTable->rowCount(); ++restoreRow) {
auto *userItem = altsTable->item(restoreRow, COL_ALTS_USER);
if (userItem != nullptr && userItem->text() == selectedUser) {
altsTable->setCurrentItem(userItem, QItemSelectionModel::Select | QItemSelectionModel::Rows);
restored = true;
break;
}
}
if (!restored) {
selectedUser.clear();
clearDetails();
}
}
}
void TabAlts::onSearchFilterChanged()
{
if (currentUser.isEmpty()) {
return;
}
// A filter change reloads the user being investigated; it must not switch
// to a name that was typed into the search box but never submitted.
reloadCurrentUser();
}
void TabAlts::onDisplayFilterChanged()
{
if (lastAlts.isEmpty()) {
return;
}
renderTable();
}
void TabAlts::investigateSelected()
{
if (!selectedUser.isEmpty() && selectedUser != currentUser) {
investigate(selectedUser);
}
}
void TabAlts::onAltSelected()
{
const auto selected = altsTable->selectedItems();
if (selected.isEmpty()) {
selectedUser.clear();
clearDetails();
return;
}
auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER);
if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) {
return;
}
selectedUser = userItem->text();
requestDetails(selectedUser);
}
void TabAlts::requestDetails(const QString &userName)
{
detailNameValue->setText(userName);
detailCountsValue->setText(tr("Loading..."));
detailNotesEdit->clear();
warnsTable->setRowCount(0);
bansTable->setRowCount(0);
investigateSelectedButton->setEnabled(userName != currentUser);
Command_ReportUserInfo infoCmd;
infoCmd.set_user_name(userName.toStdString());
PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd);
connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
userDetailsResponse(response);
}
});
client->sendCommand(infoPend);
Command_GetWarnHistory warnCmd;
warnCmd.set_user_name(userName.toStdString());
PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd);
connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
warnHistoryResponse(response);
}
});
client->sendCommand(warnPend);
Command_GetBanHistory banCmd;
banCmd.set_user_name(userName.toStdString());
PendingCommand *banPend = client->prepareModeratorCommand(banCmd);
connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
banHistoryResponse(response);
}
});
client->sendCommand(banPend);
}
void TabAlts::clearDetails()
{
detailNameValue->clear();
detailCountsValue->setText(tr("Select a row to inspect its history."));
detailNotesEdit->clear();
warnsTable->setRowCount(0);
bansTable->setRowCount(0);
investigateSelectedButton->setEnabled(false);
}
void TabAlts::userDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
detailCountsValue->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
detailCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
.arg(resp.total_reports())
.arg(resp.total_bans())
.arg(resp.total_warns()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
detailNotesEdit->setPlainText(tr("(none)"));
}
}
void TabAlts::warnHistoryResponse(const Response &response)
{
warnsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext);
warnsTable->setRowCount(resp.warn_list_size());
for (int i = 0; i < resp.warn_list_size(); ++i) {
const ServerInfo_Warning &warn = resp.warn_list(i);
warnsTable->setItem(i, COL_WARNS_TIME, new QTableWidgetItem(QString::fromStdString(warn.time_of())));
warnsTable->setItem(i, COL_WARNS_MODERATOR, new QTableWidgetItem(QString::fromStdString(warn.admin_name())));
warnsTable->setItem(i, COL_WARNS_REASON, new QTableWidgetItem(QString::fromStdString(warn.reason())));
}
}
void TabAlts::banHistoryResponse(const Response &response)
{
bansTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext);
bansTable->setRowCount(resp.ban_list_size());
for (int i = 0; i < resp.ban_list_size(); ++i) {
const ServerInfo_Ban &ban = resp.ban_list(i);
bansTable->setItem(i, COL_BANS_TIME, new QTableWidgetItem(QString::fromStdString(ban.ban_time())));
bansTable->setItem(i, COL_BANS_MODERATOR, new QTableWidgetItem(QString::fromStdString(ban.admin_name())));
bansTable->setItem(i, COL_BANS_LENGTH, new QTableWidgetItem(QString::fromStdString(ban.ban_length())));
bansTable->setItem(i, COL_BANS_REASON, new QTableWidgetItem(QString::fromStdString(ban.ban_reason())));
}
}

View file

@ -0,0 +1,102 @@
#ifndef TAB_ALTS_H
#define TAB_ALTS_H
#include "tab.h"
#include <QList>
#include <QString>
#include <QtGlobal>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <qtmetamacros.h>
class AbstractClient;
class AltTableDelegate;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QTableWidget;
class QTextEdit;
class Response;
class TabSupervisor;
/**
* Staff alt-graph investigation tool. Shows accounts related to the
* investigated user (shared email, client id, or recent IP address),
* including transitive alts-of-alts, with per-row match evidence.
* Selecting a row loads that account's warnings, bans, and admin notes.
*/
class TabAlts : public Tab
{
Q_OBJECT
public:
explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {});
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("User Alts");
}
void investigate(const QString &userName);
private slots:
void investigateUser();
void goBack();
void investigateSelected();
void altsResponse(const Response &response);
void onSearchFilterChanged();
void onDisplayFilterChanged();
void onAltSelected();
void userDetailsResponse(const Response &response);
void warnHistoryResponse(const Response &response);
void banHistoryResponse(const Response &response);
private:
void loadUser(const QString &userName);
void goToTrailEntry(const QString &userName);
void updateTrail();
void reloadCurrentUser();
void requestAlts(const QString &userName, int depth);
void requestDetails(const QString &userName);
void clearDetails();
void renderTable();
[[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const;
[[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client;
QString currentUser;
QString selectedUser;
QList<QString> backStack;
QList<ServerInfo_UserAlt> lastAlts;
QLineEdit *searchEdit;
QSpinBox *depthSpin;
QPushButton *investigateButton;
QPushButton *backButton;
QPushButton *investigateSelectedButton;
QLabel *trailLabel;
QLabel *statusLabel;
QTableWidget *altsTable;
AltTableDelegate *altsDelegate;
QPushButton *emailChip;
QPushButton *clientIdChip;
QPushButton *ipChip;
QLabel *vpnLabel;
QSpinBox *vpnSpin;
QLabel *windowLabel;
QSpinBox *monthsSpin;
QLabel *detailNameValue;
QLabel *detailCountsValue;
QTextEdit *detailNotesEdit;
QTableWidget *warnsTable;
QTableWidget *bansTable;
QGroupBox *detailsGroup;
QLabel *detailNameLabel;
QLabel *detailCountsLabel;
QLabel *detailNotesLabel;
};
#endif // TAB_ALTS_H

View file

@ -330,6 +330,9 @@ void TabGame::emitUserEvent()
TabGame::~TabGame() TabGame::~TabGame()
{ {
if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) {
delete aCardMenu->menu();
}
for (auto &player : game->getPlayerManager()->getPlayers()) { for (auto &player : game->getPlayerManager()->getPlayers()) {
player->clear(); player->clear();
} }
@ -570,8 +573,10 @@ void TabGame::adminLockChanged(bool lock)
void TabGame::actGameInfo() void TabGame::actGameInfo()
{ {
DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); // Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object.
dlg.exec(); auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
} }
void TabGame::actCopyGameLink() void TabGame::actCopyGameLink()
@ -1156,10 +1161,16 @@ QString TabGame::getTabText() const
/** /**
* @param menu The menu to set. Pass in nullptr to set the menu to empty. * @param menu The menu to set. Pass in nullptr to set the menu to empty.
*
* The previous card menu is deleted here: QAction::setMenu() does not take ownership, so whoever installs the menu
* (this widget) has to dispose of the one it replaces. The empty placeholder menu is reused instead of deleted.
*/ */
void TabGame::setCardMenu(CardMenu *menu) void TabGame::setCardMenu(CardMenu *menu)
{ {
if (!aCardMenu) { if (!aCardMenu || !emptyCardMenu) {
if (menu) {
menu->deleteLater();
}
return; return;
} }
@ -1169,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu)
gameView->viewport()->update(); gameView->viewport()->update();
} }
} }
if (menu) {
aCardMenu->setMenu(menu); QMenu *previousMenu = aCardMenu->menu();
} else { aCardMenu->setMenu(menu ? menu : emptyCardMenu);
aCardMenu->setMenu(new QMenu); if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) {
previousMenu->deleteLater();
} }
} }
@ -1256,7 +1268,8 @@ void TabGame::createMenuItems()
gameMenu->addSeparator(); gameMenu->addSeparator();
aCardMenu = gameMenu->addMenu(new QMenu(this)); emptyCardMenu = new QMenu(this);
aCardMenu = gameMenu->addMenu(emptyCardMenu);
// Sync the new action with the same state the dock button already shows. // Sync the new action with the same state the dock button already shows.
updateInviteButtonState(); updateInviteButtonState();
@ -1288,6 +1301,7 @@ void TabGame::createReplayMenuItems()
gameMenu->addAction(aLeaveGame); gameMenu->addAction(aLeaveGame);
aCardMenu = nullptr; aCardMenu = nullptr;
emptyCardMenu = nullptr;
addTabMenu(gameMenu); addTabMenu(gameMenu);
} }

View file

@ -101,6 +101,8 @@ private:
QAction *aInviteToGame = nullptr; QAction *aInviteToGame = nullptr;
QList<QAction *> phaseActions; QList<QAction *> phaseActions;
QAction *aCardMenu; QAction *aCardMenu;
/** @brief Placeholder submenu shown when no card is selected; reused instead of reallocated. */
QMenu *emptyCardMenu = nullptr;
/** /**
* @brief The actions associated with managing a QDockWidget * @brief The actions associated with managing a QDockWidget

View file

@ -1,11 +1,14 @@
#include "tab_moderation.h" #include "tab_moderation.h"
#include "../utility/epoch_format.h"
#include "../utility/table_activation.h"
#include "abstract_client.h" #include "abstract_client.h"
#include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h"
#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h" #include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h"
#include "tab_supervisor.h"
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QDateTime> #include <QDateTime>
@ -21,6 +24,7 @@
#include <QSharedPointer> #include <QSharedPointer>
#include <QSplitter> #include <QSplitter>
#include <QStringList> #include <QStringList>
#include <QTabWidget>
#include <QTableWidget> #include <QTableWidget>
#include <QTableWidgetItem> #include <QTableWidgetItem>
#include <QTextEdit> #include <QTextEdit>
@ -99,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
topBar->addWidget(removeBannerButton); topBar->addWidget(removeBannerButton);
userInfoGroup = new QGroupBox; userInfoGroup = new QGroupBox;
userInfoNameLabel = new QLabel;
userInfoNameValue = new QLabel; userInfoNameValue = new QLabel;
userInfoRegisteredLabel = new QLabel; QFont displayFont = userInfoNameValue->font();
userInfoRegisteredValue = new QLabel; displayFont.setPointSize(displayFont.pointSize() + 6);
userInfoLastLoginLabel = new QLabel; displayFont.setBold(true);
userInfoLastLoginValue = new QLabel; userInfoNameValue->setFont(displayFont);
userInfoStatusLabel = new QLabel;
userInfoStatusValue = new QLabel; userInfoStatusValue = new QLabel;
userInfoCountsLabel = new QLabel; QFont statusFont = userInfoStatusValue->font();
userInfoCountsValue = new QLabel; statusFont.setBold(true);
userInfoStatusValue->setFont(statusFont);
sublineLabel = new QLabel;
auto makeCountCard = [](const QString &title, QLabel *&value) {
auto *card = new QGroupBox(title);
value = new QLabel(QStringLiteral("–"), card);
QFont cardFont = value->font();
cardFont.setPointSize(cardFont.pointSize() + 6);
cardFont.setBold(true);
value->setFont(cardFont);
value->setAlignment(Qt::AlignCenter);
auto *cardLayout = new QVBoxLayout(card);
cardLayout->addWidget(value);
return card;
};
cardReports = makeCountCard(QString(), cardReportsValue);
cardWarns = makeCountCard(QString(), cardWarnsValue);
cardBans = makeCountCard(QString(), cardBansValue);
altsCard = new QGroupBox;
altsCountValue = new QLabel(QStringLiteral("–"), altsCard);
QFont altsFont = altsCountValue->font();
altsFont.setPointSize(altsFont.pointSize() + 6);
altsFont.setBold(true);
altsCountValue->setFont(altsFont);
altsCountValue->setAlignment(Qt::AlignCenter);
openAltsButton = new QPushButton;
connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph);
auto *altsCardLayout = new QVBoxLayout(altsCard);
altsCardLayout->addWidget(altsCountValue);
altsCardLayout->addWidget(openAltsButton);
auto *cardsRow = new QHBoxLayout;
cardsRow->addWidget(cardReports);
cardsRow->addWidget(cardWarns);
cardsRow->addWidget(cardBans);
cardsRow->addWidget(altsCard);
userInfoNotesLabel = new QLabel; userInfoNotesLabel = new QLabel;
userInfoNotesEdit = new QTextEdit; userInfoNotesEdit = new QTextEdit;
userInfoNotesEdit->setReadOnly(true); userInfoNotesEdit->setReadOnly(true);
auto *infoGrid = new QGridLayout; auto *identityRow = new QHBoxLayout;
infoGrid->addWidget(userInfoNameLabel, 0, 0); identityRow->addWidget(userInfoNameValue);
infoGrid->addWidget(userInfoNameValue, 0, 1); identityRow->addStretch();
infoGrid->addWidget(userInfoRegisteredLabel, 0, 2); identityRow->addWidget(userInfoStatusValue);
infoGrid->addWidget(userInfoRegisteredValue, 0, 3);
infoGrid->addWidget(userInfoLastLoginLabel, 1, 0);
infoGrid->addWidget(userInfoLastLoginValue, 1, 1);
infoGrid->addWidget(userInfoStatusLabel, 1, 2);
infoGrid->addWidget(userInfoStatusValue, 1, 3);
infoGrid->addWidget(userInfoCountsLabel, 2, 0);
infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3);
infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop);
infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3);
infoGrid->setColumnStretch(1, 1);
infoGrid->setColumnStretch(3, 1);
auto *infoLayout = new QVBoxLayout(userInfoGroup); auto *infoLayout = new QVBoxLayout(userInfoGroup);
infoLayout->addLayout(infoGrid); infoLayout->addLayout(identityRow);
infoLayout->addWidget(sublineLabel);
infoLayout->addLayout(cardsRow);
infoLayout->addWidget(userInfoNotesLabel);
infoLayout->addWidget(userInfoNotesEdit);
auto configureTable = [](QTableWidget *table) { auto configureTable = [](QTableWidget *table) {
table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setSelectionBehavior(QAbstractItemView::SelectRows);
@ -142,19 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
table->horizontalHeader()->setStretchLastSection(true); table->horizontalHeader()->setStretchLastSection(true);
}; };
altsGroup = new QGroupBox;
altsTable = new QTableWidget(0, COL_ALTS_COUNT); altsTable = new QTableWidget(0, COL_ALTS_COUNT);
configureTable(altsTable); configureTable(altsTable);
auto *altsLayout = new QVBoxLayout(altsGroup); connectTableActivation(altsTable, this, [this](const QModelIndex &index) {
if (!index.isValid()) {
return;
}
auto *userItem = altsTable->item(index.row(), COL_ALTS_USER);
if (userItem != nullptr && !userItem->text().isEmpty()) {
getTabSupervisor()->openTabAlts(userItem->text());
}
});
auto *altsPage = new QWidget;
auto *altsLayout = new QVBoxLayout(altsPage);
altsLayout->addWidget(altsTable); altsLayout->addWidget(altsTable);
sessionsGroup = new QGroupBox;
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
configureTable(sessionsTable); configureTable(sessionsTable);
auto *sessionsLayout = new QVBoxLayout(sessionsGroup); auto *sessionsPage = new QWidget;
auto *sessionsLayout = new QVBoxLayout(sessionsPage);
sessionsLayout->addWidget(sessionsTable); sessionsLayout->addWidget(sessionsTable);
staffGroup = new QGroupBox;
staffTable = new QTableWidget(0, COL_STAFF_COUNT); staffTable = new QTableWidget(0, COL_STAFF_COUNT);
configureTable(staffTable); configureTable(staffTable);
refreshStaffButton = new QPushButton; refreshStaffButton = new QPushButton;
@ -162,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
auto *staffHeader = new QHBoxLayout; auto *staffHeader = new QHBoxLayout;
staffHeader->addStretch(); staffHeader->addStretch();
staffHeader->addWidget(refreshStaffButton); staffHeader->addWidget(refreshStaffButton);
auto *staffLayout = new QVBoxLayout(staffGroup); auto *staffPage = new QWidget;
auto *staffLayout = new QVBoxLayout(staffPage);
staffLayout->addWidget(staffTable); staffLayout->addWidget(staffTable);
staffLayout->addLayout(staffHeader); staffLayout->addLayout(staffHeader);
sectionTabs = new QTabWidget;
sectionTabs->addTab(altsPage, QString());
sectionTabs->addTab(sessionsPage, QString());
sectionTabs->addTab(staffPage, QString());
auto *splitter = new QSplitter(Qt::Vertical); auto *splitter = new QSplitter(Qt::Vertical);
splitter->addWidget(userInfoGroup); splitter->addWidget(userInfoGroup);
splitter->addWidget(altsGroup); splitter->addWidget(sectionTabs);
splitter->addWidget(sessionsGroup);
splitter->addWidget(staffGroup);
splitter->setStretchFactor(0, 1); splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 2); splitter->setStretchFactor(1, 2);
splitter->setStretchFactor(2, 2);
splitter->setStretchFactor(3, 1);
auto *mainLayout = new QVBoxLayout(centralWidget); auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar); mainLayout->addLayout(topBar);
@ -196,45 +239,56 @@ void TabModeration::retranslateUi()
refreshStaffButton->setText(tr("Refresh")); refreshStaffButton->setText(tr("Refresh"));
userInfoGroup->setTitle(tr("User Info")); userInfoGroup->setTitle(tr("User Info"));
userInfoNameLabel->setText(tr("Name:"));
userInfoRegisteredLabel->setText(tr("Registered:"));
userInfoLastLoginLabel->setText(tr("Last login:"));
userInfoStatusLabel->setText(tr("Status:"));
userInfoCountsLabel->setText(tr("Counts:"));
userInfoNotesLabel->setText(tr("Admin notes:")); userInfoNotesLabel->setText(tr("Admin notes:"));
cardReports->setTitle(tr("Reports"));
cardWarns->setTitle(tr("Warnings"));
cardBans->setTitle(tr("Bans"));
altsCard->setTitle(tr("Alts"));
openAltsButton->setText(tr("Open graph"));
openAltsButton->setToolTip(tr("Open this user in the User Alts tab"));
altsGroup->setTitle(tr("Alts")); sectionTabs->setTabText(0, tr("Alts"));
sessionsGroup->setTitle(tr("Sessions")); sectionTabs->setTabText(1, tr("Sessions"));
staffGroup->setTitle(tr("Staff Last Logins")); sectionTabs->setTabText(2, tr("Staff Last Logins"));
if (cachedUserInfoValid) {
applyUserInfo();
}
if (cachedAltsCount >= 0) {
applyAltsCount();
}
altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"),
tr("Warns"), tr("Bans"), tr("Active")}); tr("Warns"), tr("Bans"), tr("Active")});
altsTable->setToolTip(tr("Double-click or press Enter on a row to open it in the User Alts tab"));
sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")}); sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")});
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")}); staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
} }
QString TabModeration::formatEpoch(quint64 ts) const QString TabModeration::formatEpoch(quint64 ts) const
{ {
if (ts == 0) { return formatEpochTimestamp(ts, tr("Unknown"));
return tr("Unknown");
}
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm");
} }
void TabModeration::clearUserData() void TabModeration::clearUserData()
{ {
currentUser.clear(); currentUser.clear();
cachedUserInfoValid = false;
cachedAltsCount = -1;
userInfoNameValue->clear(); userInfoNameValue->clear();
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->clear(); userInfoStatusValue->clear();
userInfoCountsValue->clear(); sublineLabel->clear();
cardReportsValue->setText(QStringLiteral("–"));
cardWarnsValue->setText(QStringLiteral("–"));
cardBansValue->setText(QStringLiteral("–"));
altsCountValue->setText(QStringLiteral("–"));
userInfoNotesEdit->clear(); userInfoNotesEdit->clear();
altsTable->setRowCount(0); altsTable->setRowCount(0);
sessionsTable->setRowCount(0); sessionsTable->setRowCount(0);
resetPasswordButton->setEnabled(false); resetPasswordButton->setEnabled(false);
removeAvatarButton->setEnabled(false); removeAvatarButton->setEnabled(false);
removeBannerButton->setEnabled(false); removeBannerButton->setEnabled(false);
openAltsButton->setEnabled(false);
} }
void TabModeration::investigate(const QString &userName) void TabModeration::investigate(const QString &userName)
@ -253,9 +307,13 @@ void TabModeration::investigateUser()
return; return;
} }
currentUser = userName; currentUser = userName;
cachedUserInfoValid = false;
cachedAltsCount = -1;
resetPasswordButton->setEnabled(true); resetPasswordButton->setEnabled(true);
removeAvatarButton->setEnabled(true); removeAvatarButton->setEnabled(true);
removeBannerButton->setEnabled(true); removeBannerButton->setEnabled(true);
openAltsButton->setEnabled(true);
altsCountValue->setText(QStringLiteral("…"));
altsTable->setRowCount(0); altsTable->setRowCount(0);
sessionsTable->setRowCount(0); sessionsTable->setRowCount(0);
requestUserInfo(userName); requestUserInfo(userName);
@ -263,13 +321,22 @@ void TabModeration::investigateUser()
requestAlts(userName); requestAlts(userName);
} }
void TabModeration::openAltsGraph()
{
if (currentUser.isEmpty()) {
return;
}
getTabSupervisor()->openTabAlts(currentUser);
}
void TabModeration::requestUserInfo(const QString &userName) void TabModeration::requestUserInfo(const QString &userName)
{ {
userInfoNameValue->setText(userName); userInfoNameValue->setText(userName);
userInfoRegisteredValue->setText(tr("Loading...")); sublineLabel->setText(tr("Loading..."));
userInfoLastLoginValue->setText(tr("Loading..."));
userInfoStatusValue->clear(); userInfoStatusValue->clear();
userInfoCountsValue->clear(); cardReportsValue->setText(QStringLiteral("…"));
cardWarnsValue->setText(QStringLiteral("…"));
cardBansValue->setText(QStringLiteral("…"));
userInfoNotesEdit->clear(); userInfoNotesEdit->clear();
Command_ReportUserInfo cmd; Command_ReportUserInfo cmd;
@ -282,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName)
void TabModeration::userInfoResponse(const Response &response) void TabModeration::userInfoResponse(const Response &response)
{ {
if (response.response_code() != Response::RespOk) { if (response.response_code() != Response::RespOk) {
userInfoRegisteredValue->clear(); cachedUserInfoValid = false;
userInfoLastLoginValue->clear(); sublineLabel->clear();
userInfoStatusValue->setText(tr("Error loading user info.")); userInfoStatusValue->setText(tr("Error loading user info."));
cardReportsValue->setText(QStringLiteral("–"));
cardWarnsValue->setText(QStringLiteral("–"));
cardBansValue->setText(QStringLiteral("–"));
return; return;
} }
@ -293,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response)
return; return;
} }
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); cachedUserInfo.CopyFrom(resp);
userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); cachedUserInfoValid = true;
applyUserInfo();
}
void TabModeration::applyUserInfo()
{
const Response_ReportUserInfo &resp = cachedUserInfo;
sublineLabel->setText(tr("Registered %1 · Last login %2")
.arg(formatEpoch(resp.registration_time()))
.arg(formatEpoch(resp.last_login())));
QStringList statusParts; QStringList statusParts;
statusParts << (resp.is_active() ? tr("active") : tr("inactive")); statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
if (resp.has_is_admin() && resp.is_admin()) { if (resp.has_is_admin() && resp.is_admin()) {
statusParts << tr("admin"); statusParts << tr("admin");
} }
userInfoStatusValue->setText(statusParts.join(", ")); userInfoStatusValue->setText(statusParts.join(QStringLiteral(", ")));
userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") cardReportsValue->setText(QString::number(resp.total_reports()));
.arg(resp.total_reports()) cardWarnsValue->setText(QString::number(resp.total_warns()));
.arg(resp.total_bans()) cardBansValue->setText(QString::number(resp.total_bans()));
.arg(resp.total_warns()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) { if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
@ -315,12 +393,21 @@ void TabModeration::userInfoResponse(const Response &response)
} }
} }
void TabModeration::applyAltsCount()
{
altsCountValue->setText(tr("%1 related").arg(cachedAltsCount));
}
void TabModeration::requestSessions(const QString &userName) void TabModeration::requestSessions(const QString &userName)
{ {
Command_GetUserSessions cmd; Command_GetUserSessions cmd;
cmd.set_user_name(userName.toStdString()); cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd); PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse); connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == currentUser) {
sessionsResponse(response);
}
});
client->sendCommand(pend); client->sendCommand(pend);
} }
@ -352,7 +439,11 @@ void TabModeration::requestAlts(const QString &userName)
Command_GetUserAlts cmd; Command_GetUserAlts cmd;
cmd.set_user_name(userName.toStdString()); cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd); PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse); connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == currentUser) {
altsResponse(response);
}
});
client->sendCommand(pend); client->sendCommand(pend);
} }
@ -360,10 +451,21 @@ void TabModeration::altsResponse(const Response &response)
{ {
altsTable->setRowCount(0); altsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) { if (response.response_code() != Response::RespOk) {
cachedAltsCount = -1;
altsCountValue->setText(QStringLiteral("–"));
return; return;
} }
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
// The server always includes the frontier user as a depth-0 seed row.
int relatedCount = 0;
for (int i = 0; i < resp.alts_size(); ++i) {
if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) {
++relatedCount;
}
}
cachedAltsCount = relatedCount;
applyAltsCount();
altsTable->setRowCount(resp.alts_size()); altsTable->setRowCount(resp.alts_size());
for (int i = 0; i < resp.alts_size(); ++i) { for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i); const ServerInfo_UserAlt &alt = resp.alts(i);

View file

@ -5,6 +5,7 @@
#include <QString> #include <QString>
#include <QtGlobal> #include <QtGlobal>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
class AbstractClient; class AbstractClient;
@ -13,6 +14,7 @@ class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class QTableWidget; class QTableWidget;
class QTabWidget;
class QTextEdit; class QTextEdit;
class Response; class Response;
class TabSupervisor; class TabSupervisor;
@ -36,6 +38,7 @@ public:
private slots: private slots:
void investigateUser(); void investigateUser();
void openAltsGraph();
void userInfoResponse(const Response &response); void userInfoResponse(const Response &response);
void sessionsResponse(const Response &response); void sessionsResponse(const Response &response);
void altsResponse(const Response &response); void altsResponse(const Response &response);
@ -53,10 +56,15 @@ private:
void requestAlts(const QString &userName); void requestAlts(const QString &userName);
void requestModeratorLogins(); void requestModeratorLogins();
void clearUserData(); void clearUserData();
void applyUserInfo();
void applyAltsCount();
[[nodiscard]] QString formatEpoch(quint64 ts) const; [[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client; AbstractClient *client;
QString currentUser; QString currentUser;
Response_ReportUserInfo cachedUserInfo;
bool cachedUserInfoValid = false;
int cachedAltsCount = -1;
QLineEdit *searchEdit; QLineEdit *searchEdit;
QPushButton *investigateButton; QPushButton *investigateButton;
@ -65,25 +73,25 @@ private:
QPushButton *removeBannerButton; QPushButton *removeBannerButton;
QGroupBox *userInfoGroup; QGroupBox *userInfoGroup;
QLabel *userInfoNameLabel;
QLabel *userInfoNameValue; QLabel *userInfoNameValue;
QLabel *userInfoRegisteredLabel;
QLabel *userInfoRegisteredValue;
QLabel *userInfoLastLoginLabel;
QLabel *userInfoLastLoginValue;
QLabel *userInfoStatusLabel;
QLabel *userInfoStatusValue; QLabel *userInfoStatusValue;
QLabel *userInfoCountsLabel; QLabel *sublineLabel;
QLabel *userInfoCountsValue; QGroupBox *cardReports;
QGroupBox *cardWarns;
QGroupBox *cardBans;
QGroupBox *altsCard;
QLabel *cardReportsValue;
QLabel *cardWarnsValue;
QLabel *cardBansValue;
QLabel *altsCountValue;
QLabel *userInfoNotesLabel; QLabel *userInfoNotesLabel;
QTextEdit *userInfoNotesEdit; QTextEdit *userInfoNotesEdit;
QGroupBox *altsGroup; QTabWidget *sectionTabs;
QTableWidget *altsTable; QTableWidget *altsTable;
QGroupBox *sessionsGroup;
QTableWidget *sessionsTable; QTableWidget *sessionsTable;
QGroupBox *staffGroup;
QTableWidget *staffTable; QTableWidget *staffTable;
QPushButton *openAltsButton;
QPushButton *refreshStaffButton; QPushButton *refreshStaffButton;
}; };

View file

@ -5,6 +5,7 @@
#include "../utility/report_utils.h" #include "../utility/report_utils.h"
#include "abstract_client.h" #include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response.pb.h"
#include "report_status_delegate.h"
#include "tab_supervisor.h" #include "tab_supervisor.h"
#include <QAbstractItemView> #include <QAbstractItemView>
@ -24,7 +25,7 @@
#include <QPushButton> #include <QPushButton>
#include <QSharedPointer> #include <QSharedPointer>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QSplitter> #include <QTabWidget>
#include <QTableWidget> #include <QTableWidget>
#include <QTableWidgetItem> #include <QTableWidgetItem>
#include <QTextCursor> #include <QTextCursor>
@ -126,26 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
table->setContextMenuPolicy(Qt::CustomContextMenu); table->setContextMenuPolicy(Qt::CustomContextMenu);
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu); connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table));
userContextMenu = new UserContextMenu(tabSupervisor, this); userContextMenu = new UserContextMenu(tabSupervisor, this);
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog);
descGroup = new QGroupBox; reportHeaderName = new QLabel;
QFont displayFont = reportHeaderName->font();
displayFont.setPointSize(displayFont.pointSize() + 6);
displayFont.setBold(true);
reportHeaderName->setFont(displayFont);
reportHeaderStatus = new QLabel;
QFont statusFont = reportHeaderStatus->font();
statusFont.setBold(true);
reportHeaderStatus->setFont(statusFont);
reportHeaderSubline = new QLabel;
auto *reportIdentityRow = new QHBoxLayout;
reportIdentityRow->addWidget(reportHeaderName);
reportIdentityRow->addStretch();
reportIdentityRow->addWidget(reportHeaderStatus);
auto *descPage = new QWidget;
descriptionEdit = new QTextEdit; descriptionEdit = new QTextEdit;
descriptionEdit->setReadOnly(true); descriptionEdit->setReadOnly(true);
auto *descLayout = new QVBoxLayout(descGroup); auto *descLayout = new QVBoxLayout(descPage);
descLayout->setContentsMargins(4, 4, 4, 4); descLayout->setContentsMargins(4, 4, 4, 4);
descLayout->addWidget(descriptionEdit); descLayout->addWidget(descriptionEdit);
chatGroup = new QGroupBox; auto *chatPage = new QWidget;
chatLogEdit = new QTextEdit; chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true); chatLogEdit->setReadOnly(true);
applyChatLogFont(); applyChatLogFont();
connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont); connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont);
auto *chatLayout = new QVBoxLayout(chatGroup); auto *chatLayout = new QVBoxLayout(chatPage);
chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit); chatLayout->addWidget(chatLogEdit);
commentsGroup = new QGroupBox; auto *commentsPage = new QWidget;
commentsEdit = new QTextEdit; commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true); commentsEdit->setReadOnly(true);
auto *commentInputRow = new QHBoxLayout; auto *commentInputRow = new QHBoxLayout;
@ -156,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
commentInputRow->addWidget(commentInput); commentInputRow->addWidget(commentInput);
commentInputRow->addWidget(commentButton); commentInputRow->addWidget(commentButton);
auto *commentsLayout = new QVBoxLayout(commentsGroup); auto *commentsLayout = new QVBoxLayout(commentsPage);
commentsLayout->setContentsMargins(4, 4, 4, 4); commentsLayout->setContentsMargins(4, 4, 4, 4);
commentsLayout->addWidget(commentsEdit); commentsLayout->addWidget(commentsEdit);
commentsLayout->addLayout(commentInputRow); commentsLayout->addLayout(commentInputRow);
userContextGroup = new QGroupBox; auto *userPage = new QWidget;
userContextName = new QLabel; userContextName = new QLabel;
userContextAccountAge = new QLabel; userContextAccountAge = new QLabel;
userContextReports = new QLabel; userContextReports = new QLabel;
@ -180,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
userContextWarnsLabel = new QLabel; userContextWarnsLabel = new QLabel;
userContextNotesLabel = new QLabel; userContextNotesLabel = new QLabel;
userContextRecentReportsLabel = new QLabel; userContextRecentReportsLabel = new QLabel;
auto *ucLayout = new QGridLayout(userContextGroup); auto *ucLayout = new QGridLayout(userPage);
ucLayout->setContentsMargins(8, 8, 8, 8); ucLayout->setContentsMargins(8, 8, 8, 8);
ucLayout->addWidget(userContextUserLabel, 0, 0); ucLayout->addWidget(userContextUserLabel, 0, 0);
ucLayout->addWidget(userContextName, 0, 1, 1, 3); ucLayout->addWidget(userContextName, 0, 1, 1, 3);
@ -196,7 +214,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
ucLayout->addWidget(userContextNotes, 3, 1, 1, 3); ucLayout->addWidget(userContextNotes, 3, 1, 1, 3);
ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4); ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4);
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4); ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
userContextGroup->setVisible(false);
statsGroup = new QGroupBox; statsGroup = new QGroupBox;
statsGroup->setCheckable(true); statsGroup->setCheckable(true);
@ -223,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
} }
}); });
detailSplitter = new QSplitter(Qt::Vertical); detailTabs = new QTabWidget;
detailSplitter->addWidget(descGroup); detailTabs->addTab(descPage, QString());
detailSplitter->addWidget(chatGroup); detailTabs->addTab(chatPage, QString());
detailSplitter->addWidget(commentsGroup); detailTabs->addTab(commentsPage, QString());
detailSplitter->setStretchFactor(0, 1); detailTabs->addTab(userPage, QString());
detailSplitter->setStretchFactor(1, 1);
detailSplitter->setStretchFactor(2, 2);
assignButton = new QPushButton; assignButton = new QPushButton;
resolveButton = new QPushButton; resolveButton = new QPushButton;
@ -261,12 +276,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
layout->addLayout(topBar); layout->addLayout(topBar);
layout->addWidget(statsLabel); layout->addWidget(statsLabel);
layout->addWidget(table, 2); layout->addWidget(table, 2);
layout->addWidget(detailSplitter, 1); layout->addLayout(reportIdentityRow);
layout->addWidget(userContextGroup); layout->addWidget(reportHeaderSubline);
layout->addWidget(detailTabs, 2);
layout->addWidget(statsGroup); layout->addWidget(statsGroup);
layout->addLayout(actionBar); layout->addLayout(actionBar);
retranslateUi(); retranslateUi();
clearReportHeader();
setActionsEnabled(false); setActionsEnabled(false);
refreshList(); refreshList();
@ -293,18 +310,18 @@ void TabReport::retranslateUi()
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"), table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")}); tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
descGroup->setTitle(tr("Description")); detailTabs->setTabText(0, tr("Description"));
chatGroup->setTitle(tr("Chat Log Context")); detailTabs->setTabText(1, tr("Chat Log Context"));
chatGroup->setToolTip( detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be "
tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited.")); "incomplete or edited."));
commentsGroup->setTitle(tr("Comments / Thread")); detailTabs->setTabText(2, tr("Comments / Thread"));
detailTabs->setTabText(3, tr("Reported User"));
descriptionEdit->setPlaceholderText(tr("No description.")); descriptionEdit->setPlaceholderText(tr("No description."));
chatLogEdit->setPlaceholderText(tr("No chat log attached.")); chatLogEdit->setPlaceholderText(tr("No chat log attached."));
commentsEdit->setPlaceholderText(tr("No comments yet.")); commentsEdit->setPlaceholderText(tr("No comments yet."));
commentInput->setPlaceholderText(tr("Type a reply...")); commentInput->setPlaceholderText(tr("Type a reply..."));
commentButton->setText(tr("Send")); commentButton->setText(tr("Send"));
userContextGroup->setTitle(tr("Reported User Context"));
userContextUserLabel->setText(tr("User:")); userContextUserLabel->setText(tr("User:"));
userContextAgeLabel->setText(tr("Account Age:")); userContextAgeLabel->setText(tr("Account Age:"));
userContextReportsLabel->setText(tr("Reports:")); userContextReportsLabel->setText(tr("Reports:"));
@ -312,10 +329,18 @@ void TabReport::retranslateUi()
userContextWarnsLabel->setText(tr("Warns:")); userContextWarnsLabel->setText(tr("Warns:"));
userContextNotesLabel->setText(tr("Admin Notes:")); userContextNotesLabel->setText(tr("Admin Notes:"));
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:")); userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
userContextAccountAge->setText(QString());
userContextReports->setText(QString()); if (headerReportId >= 0) {
userContextBans->setText(QString()); for (const ServerInfo_Report &r : filteredReports) {
userContextWarns->setText(QString()); if (r.report_id() == headerReportId) {
updateReportHeader(r);
const QString status = QString::fromStdString(r.status());
const bool canComment = (status == "open" || status == "assigned");
commentInput->setPlaceholderText(canComment ? tr("Type a reply...") : tr("This report is closed."));
break;
}
}
}
statsGroup->setTitle(tr("Report Statistics")); statsGroup->setTitle(tr("Report Statistics"));
@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response)
chatLogEdit->clear(); chatLogEdit->clear();
commentsEdit->clear(); commentsEdit->clear();
setActionsEnabled(false); setActionsEnabled(false);
userContextGroup->setVisible(false); clearReportHeader();
clearUserContext();
} }
} }
@ -445,11 +471,14 @@ void TabReport::applyFilters()
table->setSortingEnabled(false); table->setSortingEnabled(false);
table->setRowCount(filteredReports.size()); table->setRowCount(filteredReports.size());
QFont reportedFont = table->font();
reportedFont.setBold(true);
for (int row = 0; row < filteredReports.size(); ++row) { for (int row = 0; row < filteredReports.size(); ++row) {
const ServerInfo_Report &r = filteredReports[row]; const ServerInfo_Report &r = filteredReports[row];
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
COL_STATUS, COL_ASSIGNED); COL_STATUS, COL_ASSIGNED, false);
table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name())));
@ -458,12 +487,30 @@ void TabReport::applyFilters()
table->setItem(row, COL_ROOM, table->setItem(row, COL_ROOM,
new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : ""));
if (auto *reportedItem = table->item(row, COL_REPORTED)) {
reportedItem->setFont(reportedFont);
}
} }
table->setSortingEnabled(true); table->setSortingEnabled(true);
table->resizeColumnsToContents(); table->resizeColumnsToContents();
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
if (headerReportId >= 0) {
bool stillVisible = false;
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == headerReportId) {
stillVisible = true;
break;
}
}
if (!stillVisible) {
clearReportHeader();
clearUserContext();
}
}
statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size()));
} }
@ -490,6 +537,31 @@ void TabReport::updateStats()
.arg(dismissed)); .arg(dismissed));
} }
void TabReport::updateReportHeader(const ServerInfo_Report &report)
{
reportHeaderName->setText(QString::fromStdString(report.reported_user_name()));
reportHeaderStatus->setText(report_utils::formatReportStatus(QString::fromStdString(report.status())));
QStringList parts;
parts << QString::fromStdString(report.category());
parts << QDateTime::fromSecsSinceEpoch(report.report_time()).toLocalTime().toString("yyyy-MM-dd HH:mm");
if (report.has_room_id() && report.room_id() > 0) {
parts << tr("Room %1").arg(report.room_id());
}
if (report.game_id() > 0) {
parts << tr("Game %1").arg(report.game_id());
}
reportHeaderSubline->setText(parts.join(QStringLiteral(" · ")));
}
void TabReport::clearReportHeader()
{
headerReportId = -1;
reportHeaderName->setText(tr("Select a report to inspect."));
reportHeaderStatus->clear();
reportHeaderSubline->clear();
}
void TabReport::onSelectionChanged() void TabReport::onSelectionChanged()
{ {
const int row = table->currentRow(); const int row = table->currentRow();
@ -500,7 +572,8 @@ void TabReport::onSelectionChanged()
commentInput->clear(); commentInput->clear();
commentButton->setEnabled(false); commentButton->setEnabled(false);
setActionsEnabled(false); setActionsEnabled(false);
userContextGroup->setVisible(false); clearReportHeader();
clearUserContext();
return; return;
} }
@ -509,6 +582,18 @@ void TabReport::onSelectionChanged()
updateActionStates(); updateActionStates();
} }
void TabReport::clearUserContext()
{
lastRequestedUser.clear();
userContextName->setText(tr("—"));
userContextAccountAge->clear();
userContextReports->clear();
userContextBans->clear();
userContextWarns->clear();
userContextNotes->setPlainText(tr("Select a report to inspect."));
userContextRecentReports->clear();
}
void TabReport::showTableContextMenu(const QPoint &pos) void TabReport::showTableContextMenu(const QPoint &pos)
{ {
QTableWidgetItem *clickedItem = table->itemAt(pos); QTableWidgetItem *clickedItem = table->itemAt(pos);
@ -546,12 +631,17 @@ void TabReport::loadReportDetails(int reportId)
for (const ServerInfo_Report &r : filteredReports) { for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) { if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description())); descriptionEdit->setPlainText(QString::fromStdString(r.description()));
updateReportHeader(r);
if (headerReportId != reportId) {
headerReportId = reportId;
detailTabs->setCurrentIndex(0);
}
QString reportedUser = QString::fromStdString(r.reported_user_name()); QString reportedUser = QString::fromStdString(r.reported_user_name());
if (!reportedUser.isEmpty()) { if (!reportedUser.isEmpty()) {
requestUserInfo(reportedUser); requestUserInfo(reportedUser);
} else { } else {
userContextGroup->setVisible(false); clearUserContext();
} }
QString status = QString::fromStdString(r.status()); QString status = QString::fromStdString(r.status());
@ -871,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response)
void TabReport::requestUserInfo(const QString &userName) void TabReport::requestUserInfo(const QString &userName)
{ {
if (userName.isEmpty()) { if (userName.isEmpty()) {
userContextGroup->setVisible(false); clearUserContext();
return; return;
} }
lastRequestedUser = userName; lastRequestedUser = userName;
userContextGroup->setVisible(true);
userContextName->setText(userName); userContextName->setText(userName);
userContextAccountAge->setText(tr("Loading...")); userContextAccountAge->setText(tr("Loading..."));
userContextReports->clear(); userContextReports->clear();

View file

@ -16,8 +16,8 @@ class QGroupBox;
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class QSplitter;
class QTableWidget; class QTableWidget;
class QTabWidget;
class QTextEdit; class QTextEdit;
class QTimer; class QTimer;
class GameReplay; class GameReplay;
@ -72,6 +72,9 @@ private:
void applyFilters(); void applyFilters();
void updateStats(); void updateStats();
void loadReportDetails(int reportId); void loadReportDetails(int reportId);
void updateReportHeader(const ServerInfo_Report &report);
void clearReportHeader();
void clearUserContext();
AbstractClient *client; AbstractClient *client;
@ -81,10 +84,10 @@ private:
QPushButton *refreshButton; QPushButton *refreshButton;
QLabel *statsLabel; QLabel *statsLabel;
QTableWidget *table; QTableWidget *table;
QSplitter *detailSplitter; QLabel *reportHeaderName;
QGroupBox *descGroup; QLabel *reportHeaderStatus;
QGroupBox *chatGroup; QLabel *reportHeaderSubline;
QGroupBox *commentsGroup; QTabWidget *detailTabs;
QTextEdit *descriptionEdit; QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit; QTextEdit *chatLogEdit;
QTextEdit *commentsEdit; QTextEdit *commentsEdit;
@ -100,7 +103,6 @@ private:
QTimer *refreshTimer; QTimer *refreshTimer;
UserContextMenu *userContextMenu; UserContextMenu *userContextMenu;
QGroupBox *userContextGroup;
QLabel *userContextName; QLabel *userContextName;
QLabel *userContextAccountAge; QLabel *userContextAccountAge;
QLabel *userContextReports; QLabel *userContextReports;
@ -125,6 +127,7 @@ private:
QList<ServerInfo_Report> allReports; QList<ServerInfo_Report> allReports;
QList<ServerInfo_Report> filteredReports; QList<ServerInfo_Report> filteredReports;
int headerReportId = -1;
int detailsRequestedReportId = -1; int detailsRequestedReportId = -1;
int detailsRequestSeq = 0; int detailsRequestSeq = 0;
int selectedReportIdBeforeRefresh = -1; int selectedReportIdBeforeRefresh = -1;

View file

@ -22,6 +22,7 @@
#include "tab.h" #include "tab.h"
#include "tab_account.h" #include "tab_account.h"
#include "tab_admin.h" #include "tab_admin.h"
#include "tab_alts.h"
#include "tab_card_art_rules.h" #include "tab_card_art_rules.h"
#include "tab_deck_editor.h" #include "tab_deck_editor.h"
#include "tab_deck_storage.h" #include "tab_deck_storage.h"
@ -157,7 +158,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{ {
setObjectName("tabSupervisor"); setObjectName("tabSupervisor");
@ -575,6 +576,7 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
tabLog = nullptr; tabLog = nullptr;
tabReport = nullptr; tabReport = nullptr;
tabModeration = nullptr; tabModeration = nullptr;
tabAlts = nullptr;
tabDeveloper = nullptr; tabDeveloper = nullptr;
isLocalGame = true; isLocalGame = true;
userInfo = new ServerInfo_User; userInfo = new ServerInfo_User;
@ -623,6 +625,9 @@ void TabSupervisor::stop()
if (tabModeration) { if (tabModeration) {
tabModeration->close(); tabModeration->close();
} }
if (tabAlts) {
tabAlts->close();
}
if (tabCardArtRules) { if (tabCardArtRules) {
tabCardArtRules->close(); tabCardArtRules->close();
} }
@ -931,6 +936,21 @@ void TabSupervisor::openTabModeration(const QString &userName)
aTabModeration->setChecked(true); aTabModeration->setChecked(true);
} }
void TabSupervisor::openTabAlts(const QString &userName)
{
if (tabAlts) {
setCurrentWidget(tabAlts);
if (!userName.isEmpty()) {
tabAlts->investigate(userName);
}
return;
}
tabAlts = new TabAlts(this, client, userName);
myAddTab(tabAlts);
setCurrentWidget(tabAlts);
connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; });
}
void TabSupervisor::actTabDeveloper(bool checked) void TabSupervisor::actTabDeveloper(bool checked)
{ {
if (checked && !tabDeveloper) { if (checked && !tabDeveloper) {

View file

@ -50,6 +50,7 @@ class TabAdmin;
class TabMessage; class TabMessage;
class TabReport; class TabReport;
class TabModeration; class TabModeration;
class TabAlts;
class TabAccount; class TabAccount;
class TabDeckEditor; class TabDeckEditor;
class TabDeveloper; class TabDeveloper;
@ -116,6 +117,7 @@ private:
TabLog *tabLog; TabLog *tabLog;
TabReport *tabReport; TabReport *tabReport;
TabModeration *tabModeration; TabModeration *tabModeration;
TabAlts *tabAlts;
TabDeveloper *tabDeveloper; TabDeveloper *tabDeveloper;
QMap<int, TabRoom *> roomTabs; QMap<int, TabRoom *> roomTabs;
QMap<int, TabGame *> gameTabs; QMap<int, TabGame *> gameTabs;
@ -209,6 +211,7 @@ public slots:
/** Joins or spectates a game listed on a user's profile hover card. */ /** Joins or spectates a game listed on a user's profile hover card. */
void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); void joinGameFromUserCard(int gameId, int roomId, bool asSpectator);
void openTabModeration(const QString &userName = {}); void openTabModeration(const QString &userName = {});
void openTabAlts(const QString &userName = {});
void switchToFirstAvailableNetworkTab(); void switchToFirstAvailableNetworkTab();
void maximizeMainWindow(); void maximizeMainWindow();
void actTabVisualDeckStorage(bool checked); void actTabVisualDeckStorage(bool checked);

View file

@ -0,0 +1,27 @@
/**
* @file epoch_format.h
* @brief Shared timestamp formatting for the moderation tables.
*/
#ifndef EPOCH_FORMAT_H
#define EPOCH_FORMAT_H
#include <QDateTime>
#include <QString>
/**
* @brief Format a unix timestamp the way the moderation tabs show dates.
*
* @param ts Seconds since the epoch; 0 means "unknown".
* @param unknownText Label returned for 0, translated by the caller so the
* translation context stays with the tab.
*/
inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText)
{
if (ts == 0) {
return unknownText;
}
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm"));
}
#endif // EPOCH_FORMAT_H

View file

@ -0,0 +1,41 @@
#include "pill_painter.h"
#include <QApplication>
#include <QFontMetrics>
#include <QPainter>
#include <QRect>
#include <QStyle>
#include <QStyleOptionViewItem>
namespace
{
constexpr qreal PILL_ALPHA = 0.30;
} // namespace
bool pill_painter::paint(QPainter *painter,
const QStyleOptionViewItem &option,
QPalette::ColorRole tintRole,
QPalette::ColorRole textRole,
const QString &text)
{
const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2);
if (pillRect.width() <= 0 || pillRect.height() <= 0) {
return false;
}
if ((option.state & QStyle::State_Selected) == 0) {
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
QColor pill = option.palette.color(tintRole);
pill.setAlphaF(PILL_ALPHA);
painter->setPen(Qt::NoPen);
painter->setBrush(pill);
painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0);
painter->restore();
}
QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style();
const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width());
style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole);
return true;
}

View file

@ -0,0 +1,29 @@
#ifndef PILL_PAINTER_H
#define PILL_PAINTER_H
#include <QPalette>
#include <QString>
class QPainter;
class QStyleOptionViewItem;
namespace pill_painter
{
constexpr int PAD_X = 10;
constexpr int PAD_Y = 4;
/**
* Draws a translucent, palette-tinted rounded pill inside the option's rect and
* paints the text elided and centred on it, so badges follow the active theme.
* Returns false when the cell is too small to hold a pill, leaving the caller to
* fall back to default painting.
*/
bool paint(QPainter *painter,
const QStyleOptionViewItem &option,
QPalette::ColorRole tintRole,
QPalette::ColorRole textRole,
const QString &text);
} // namespace pill_painter
#endif // PILL_PAINTER_H

View file

@ -80,6 +80,23 @@ QString formatReportTime(qint64 secondsSinceEpoch)
return dt.toString("yyyy-MM-dd hh:mm"); return dt.toString("yyyy-MM-dd hh:mm");
} }
QString formatReportStatus(const QString &status)
{
if (status == QStringLiteral("open")) {
return QObject::tr("Open");
}
if (status == QStringLiteral("assigned")) {
return QObject::tr("Assigned");
}
if (status == QStringLiteral("resolved")) {
return QObject::tr("Resolved");
}
if (status == QStringLiteral("dismissed")) {
return QObject::tr("Dismissed");
}
return status;
}
void fillReportTableRow(QTableWidget *table, void fillReportTableRow(QTableWidget *table,
int row, int row,
const ServerInfo_Report &report, const ServerInfo_Report &report,
@ -89,7 +106,8 @@ void fillReportTableRow(QTableWidget *table,
int categoryColumn, int categoryColumn,
int gameIdColumn, int gameIdColumn,
int statusColumn, int statusColumn,
int assignedColumn) int assignedColumn,
bool colorizeStatus)
{ {
auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
idItem->setData(Qt::UserRole, report.report_id()); idItem->setData(Qt::UserRole, report.report_id());
@ -102,8 +120,12 @@ void fillReportTableRow(QTableWidget *table,
table->setItem(row, gameIdColumn, table->setItem(row, gameIdColumn,
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString())); new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status())); const QString rawStatus = QString::fromStdString(report.status());
setReportStatusItemColor(statusItem, QString::fromStdString(report.status())); auto *statusItem = new QTableWidgetItem(formatReportStatus(rawStatus));
statusItem->setData(Qt::UserRole, rawStatus);
if (colorizeStatus) {
setReportStatusItemColor(statusItem, rawStatus);
}
table->setItem(row, statusColumn, statusItem); table->setItem(row, statusColumn, statusItem);
table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));

View file

@ -20,6 +20,7 @@ QFont reportLogFont();
QString formatReportCategory(const QString &category); QString formatReportCategory(const QString &category);
QString formatReportTime(qint64 secondsSinceEpoch); QString formatReportTime(qint64 secondsSinceEpoch);
QString formatReportStatus(const QString &status);
void fillReportTableRow(QTableWidget *table, void fillReportTableRow(QTableWidget *table,
int row, int row,
@ -30,7 +31,8 @@ void fillReportTableRow(QTableWidget *table,
int categoryColumn, int categoryColumn,
int gameIdColumn, int gameIdColumn,
int statusColumn, int statusColumn,
int assignedColumn); int assignedColumn,
bool colorizeStatus = true);
void renderReportDetails(QTextEdit *chatLogEdit, void renderReportDetails(QTextEdit *chatLogEdit,
QTextEdit *commentsEdit, QTextEdit *commentsEdit,

View file

@ -0,0 +1,28 @@
#include "table_activation.h"
#include <QKeySequence>
#include <QShortcut>
#include <QTableWidget>
namespace
{
void addActivationShortcut(QTableWidget *table,
QObject *context,
int key,
const std::function<void(const QModelIndex &)> &handler)
{
auto *shortcut = new QShortcut(QKeySequence(key), table);
// The table's viewport takes focus, so match children as well.
shortcut->setContext(Qt::WidgetWithChildrenShortcut);
QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); });
}
} // namespace
void connectTableActivation(QTableWidget *table,
QObject *context,
const std::function<void(const QModelIndex &)> &handler)
{
QObject::connect(table, &QTableWidget::doubleClicked, context, handler);
addActivationShortcut(table, context, Qt::Key_Return, handler);
addActivationShortcut(table, context, Qt::Key_Enter, handler);
}

View file

@ -0,0 +1,32 @@
/**
* @file table_activation.h
* @brief Row activation for tables that must not navigate on a single click.
*/
#ifndef TABLE_ACTIVATION_H
#define TABLE_ACTIVATION_H
#include <functional>
class QModelIndex;
class QObject;
class QTableWidget;
/**
* @brief Call @p handler on double-click or Enter, never on a single click.
*
* QAbstractItemView::activated fires on a single click on platforms where
* SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not
* use it. This connects doubleClicked and installs Return/Enter shortcuts that
* forward the current index.
*
* @param table The table whose rows activate.
* @param context Object that owns the connections; the handler is disconnected
* with it.
* @param handler Receives the activated row's index, which may be invalid.
*/
void connectTableActivation(QTableWidget *table,
QObject *context,
const std::function<void(const QModelIndex &)> &handler);
#endif // TABLE_ACTIVATION_H

View file

@ -15,9 +15,23 @@
#include <QtGlobal> #include <QtGlobal>
#include <libcockatrice/filters/filter_tree.h> #include <libcockatrice/filters/filter_tree.h>
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 * This widget provides a graphical control element for the CardFilter::Attr::AttrColor and
* model. * CardFilter::Attr::AttrColorIdentity filters applied to the filter model.
* @param parent The Qt Widget that this widget will parent to * @param parent The Qt Widget that this widget will parent to
* @param _filterModel The filter model that this widget will manipulate * @param _filterModel The filter model that this widget will manipulate
*/ */
@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
modeComboBox->blockSignals(true); modeComboBox->blockSignals(true);
modeComboBox->clear(); modeComboBox->clear();
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes)); 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("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->setToolTip(tr("How selected and unselected colors are combined in the filter"));
modeComboBox->setAccessibleName(tr("Color filter mode"));
// Restore current mode // Restore current mode
const int index = modeComboBox->findData(QVariant::fromValue(currentMode)); const int index = modeComboBox->findData(QVariant::fromValue(currentMode));
@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
modeComboBox->blockSignals(false); 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) void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active)
{ {
if (active) { if (active) {
@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo
void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color) void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color)
{ {
QString colorString = color; FilterModeInfo info = getFilterModeInfo(currentMode);
QString typeStr;
// Remove previous filters removeFilters(filterModel, color, info.attr);
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); if (info.includeType != CardFilter::Type::TypeEnd) {
QList<const CardFilter *> matchingFilters; filterModel->addFilter(new CardFilter(color, info.includeType, info.attr));
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;
} }
} }
void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color) void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color)
{ {
QString colorString = color; FilterModeInfo info = getFilterModeInfo(currentMode);
// Remove inclusion filters removeFilters(filterModel, color, info.attr);
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> matchingFilters;
for (const CardFilter *filter : allColorFilters) { // Add exclusion filters if the mode demands it.
if (filter->term() == color) { if (info.excludeType != CardFilter::Type::TypeEnd) {
matchingFilters.append(filter); filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr));
}
}
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;
} }
} }
@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode()
filterModel->filterTree()->blockSignals(true); filterModel->filterTree()->blockSignals(true);
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor); filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor);
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity);
const QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>(); const QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
@ -211,52 +212,53 @@ QList<QChar> VisualDatabaseDisplayColorFilterWidget::getActiveColors()
void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel() void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel()
{ {
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); QList<const CardFilter *> filters;
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor));
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity));
QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>(); // 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<ManaSymbolWidget *>()) {
bool found = false; bool found = false;
for (const CardFilter *filter : allColorFilters) { for (const CardFilter *filter : filters) {
if (manaSymbolWidget->getSymbolChar() == filter->term()) { if (filter->type() == filterType && 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;
}
found = true; found = true;
break;
} }
} }
if (!found) { manaSymbolWidget->setColorActive(useExclude ? !found : found);
setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false); manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom);
}
} }
} }

View file

@ -10,8 +10,8 @@
#include <QByteArray> #include <QByteArray>
#include <QChar> #include <QChar>
#include <QList> #include <QList>
#include <QMetaType>
#include <QWidget> #include <QWidget>
#include <libcockatrice/filters/filter_mode.h>
#include <libcockatrice/utility/debouncer.h> #include <libcockatrice/utility/debouncer.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
@ -38,15 +38,6 @@ private:
int circleDiameter; 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 class VisualDatabaseDisplayColorFilterWidget : public QWidget
{ {
Q_OBJECT Q_OBJECT
@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget
public: public:
explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel); explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel);
void retranslateUi(); void retranslateUi();
void setFilterMode(FilterMode filterMode);
signals: signals:
void filterModeChanged(FilterMode filterMode); void filterModeChanged(FilterMode filterMode);
private slots: private slots:
void addCustomFilterModeItem();
void handleColorToggled(QChar color, bool active); void handleColorToggled(QChar color, bool active);
void addFilter(QChar color); void addFilter(QChar color);
void removeFilter(QChar color); void removeFilter(QChar color);

View file

@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(clearFilterWidget, &QToolButton::clicked, this, [this] { connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
filterModel->blockSignals(true); filterModel->blockSignals(true);
filterModel->filterTree()->blockSignals(true); filterModel->filterTree()->blockSignals(true);
colorFilterWidget->setFilterMode(FilterMode::Includes);
filterModel->clear(); filterModel->clear();
filterModel->blockSignals(false); filterModel->blockSignals(false);
filterModel->filterTree()->blockSignals(false); filterModel->filterTree()->blockSignals(false);

View file

@ -10,7 +10,7 @@
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QFutureWatcher> #include <QFuture>
#include <QMetaObject> #include <QMetaObject>
#include <QMetaType> #include <QMetaType>
#include <QSet> #include <QSet>
@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan()
// The scan (directory walk + one stat per file) runs on a worker thread so that // The scan (directory walk + one stat per file) runs on a worker thread so that
// constructing the widget never stalls the UI thread on a large deck folder. // constructing the widget never stalls the UI thread on a large deck folder.
auto *watcher = new QFutureWatcher<DeckScanResult>(this); // The continuation is bound to this model as its context object: it runs on the UI thread,
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] { // and Qt drops it if the model is destroyed first, so no result is delivered to a dead model.
watcher->deleteLater(); QFuture<DeckScanResult> scan =
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); });
scan.then(this, [this, generation](const DeckScanResult &result) {
if (generation != scanGeneration) { if (generation != scanGeneration) {
return; // A newer scan started while this one was running; drop the stale result. return; // A newer scan started while this one was running; drop the stale result.
} }
const DeckScanResult result = watcher->result();
folderPaths = result.folderPaths; folderPaths = result.folderPaths;
if (result.decks.isEmpty()) { if (result.decks.isEmpty()) {
@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan()
beginLoad(row); beginLoad(row);
} }
}); });
watcher->setFuture(
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
} }
void VisualDeckStorageModel::beginLoad(int row) void VisualDeckStorageModel::beginLoad(int row)
@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row)
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath); const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
const int generation = scanGeneration; const int generation = scanGeneration;
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this); auto load = QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
connect(watcher, &QFutureWatcher<std::optional<DeckLoadResult>>::finished, this,
[this, watcher, filePath, generation] {
watcher->deleteLater();
if (generation != scanGeneration) {
return; // The deck list was re-scanned while this load was running; drop the stale result.
}
// Queue the result and apply a bounded number per event loop turn so that
// finishing hundreds of loads at once cannot stall the UI thread.
const std::optional<DeckLoadResult> result = watcher->result();
PendingDeckLoad pending;
pending.filePath = filePath;
pending.generation = generation;
if (result) {
pending.ok = true;
pending.deck = std::move(result->deck);
pending.lastModified = result->lastModified;
pending.colorIdentity = std::move(result->colorIdentity);
}
pendingLoads.append(std::move(pending));
schedulePendingLoadDrain();
});
watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false); std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
if (!deck) { if (!deck) {
return std::nullopt; return std::nullopt;
@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row)
// keep the completion handler on the UI thread cheap. // keep the completion handler on the UI thread cheap.
const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query()); const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query());
return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity};
})); });
// Bound to this model as context: runs on the UI thread and is dropped if the model is destroyed first.
load.then(this, [this, filePath, generation](std::optional<DeckLoadResult> result) {
if (generation != scanGeneration) {
return; // The deck list was re-scanned while this load was running; drop the stale result.
}
// Queue the result and apply a bounded number per event loop turn so that
// finishing hundreds of loads at once cannot stall the UI thread.
PendingDeckLoad pending;
pending.filePath = filePath;
pending.generation = generation;
if (result) {
pending.ok = true;
pending.deck = std::move(result->deck);
pending.lastModified = result->lastModified;
pending.colorIdentity = std::move(result->colorIdentity);
}
pendingLoads.append(std::move(pending));
schedulePendingLoadDrain();
});
} }
void VisualDeckStorageModel::schedulePendingLoadDrain() void VisualDeckStorageModel::schedulePendingLoadDrain()

View file

@ -1,11 +1,11 @@
## Building Doxygen Documentation ## Building Doxygen Documentation
We use [Doxygen](https://www.doxygen.nl) for our code documentation. We use [Doxygen](https://www.doxygen.nl) for our code documentation.
After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): After installing the tool, you can build a HTML webpage into the `/docs-output` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile):
```bash ```bash
doxygen Doxyfile doxygen Doxyfile
``` ```
Open the `index.html` file in the `/docs/html/` folder to browse the result locally. Open the `index.html` file in the `/docs-output/html/` folder to browse the result locally.
There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs) 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's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual).
@ -25,4 +25,4 @@ FULL_SIDEBAR = NO # Required for doxygen-awesome
<br> <br>
Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/doc/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions. Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/docs/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions.

Some files were not shown because too many files have changed in this diff Show more