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 \
base-devel \
ccache \
cmake \
gcc \
git \
gtest \
mariadb-libs \

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,6 +20,7 @@
# --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
# (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
# Read arguments
@ -138,17 +139,21 @@ done
set -e
# Setup
./servatrice/check_schema_version.sh
if [[ ! $BUILDTYPE ]]; then
# Schema version consistency (only relevant to the server)
if [[ ${MAKE_SERVER:-} == "1" ]]; then
./servatrice/check_schema_version.sh
fi
# Use default values if unset
if [[ -z $BUILDTYPE ]]; then
BUILDTYPE=Release
fi
if [[ ! $BUILD_DIR ]]; then
if [[ -z $BUILD_DIR ]]; then
BUILD_DIR="build"
fi
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
# 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
@ -163,16 +168,16 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10
# Add cmake flags
flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE")
if [[ $MAKE_SERVER ]]; then
if [[ ${MAKE_SERVER:-} == "1" ]]; then
flags+=("-DWITH_SERVER=1")
fi
if [[ $MAKE_NO_CLIENT ]]; then
if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then
flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0")
fi
if [[ $MAKE_TEST ]]; then
if [[ ${MAKE_TEST:-} == "1" ]]; then
flags+=("-DTEST=1")
fi
if [[ $MAKE_SANITIZE ]]; then
if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
flags+=("-DENABLE_SANITIZERS=ON")
else
# 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.
flags+=("-DENABLE_SANITIZERS=OFF")
fi
if [[ $MAKE_COVERAGE ]]; then
if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
flags+=("-DENABLE_COVERAGE=ON")
fi
if [[ $USE_CCACHE ]]; then
if [[ ${USE_CCACHE:-} == "1" ]]; then
flags+=("-DUSE_CCACHE=1")
if [[ $CCACHE_SIZE ]]; then
# note, this setting persists after running the script
if [[ -n $CCACHE_SIZE ]]; then
# This setting persists after running the script
ccache --max-size "$CCACHE_SIZE"
fi
fi
if [[ $PACKAGE_TYPE ]]; then
if [[ -n $PACKAGE_TYPE ]]; then
flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE")
fi
if [[ $USE_VCPKG ]]; then
if [[ ${USE_VCPKG:-} == "1" ]]; then
flags+=("-DUSE_VCPKG=1")
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
fi
@ -203,6 +208,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then
# developer builds never reach Sentry.
flags+=("-DWITH_CRASH_REPORTING=ON")
fi
if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then
# CI lowers the DWARF level so one build configuration fits its compiler-cache bound;
# see the option in CMakeLists.txt. Empty keeps the built-in flags.
flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL")
fi
if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then
# Release-only: Debug builds stay DSN-free so crashes from developer builds
# never reach Sentry. Empty (local builds, forks without the secret, and non-tag
@ -216,7 +226,8 @@ fi
buildflags=(--config "$BUILDTYPE")
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
if got="$(ccache --show-stats --verbose 2>/dev/null)"; then
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
export QTDIR
if [[ $TARGET_MACOS_VERSION ]]; then
# CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version
if [[ -n $TARGET_MACOS_VERSION ]]; then
# 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")
# vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
# an easy way is to copy the x64-osx.cmake file and modify it
triplets_dir="/tmp/cmake/triplets"
triplet_version="custom-triplet"
triplet_file="$triplets_dir/$triplet_version.cmake"
arch=$(uname -m)
if [[ $arch == x86_64 ]]; then
arch="x64"
if [[ ${USE_VCPKG:-} == "1" ]]; then
# vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
# An easy way is to copy the x64-osx.cmake file and modify it
triplets_dir="/tmp/cmake/triplets"
triplet_version="custom-triplet"
triplet_file="$triplets_dir/$triplet_version.cmake"
arch=$(uname -m)
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
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
echo "::group::Signing Certificate"
echo "::group::Setup signing certificate"
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then
echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12"
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
@ -306,9 +319,13 @@ if [[ $RUNNER_OS == macOS ]]; then
fi
echo "::endgroup::"
if [[ $MAKE_PACKAGE ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522
# have hdiutil repeat the command 10 times in hope of success.
if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522: XProtect holds a
# 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
# 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
@ -317,17 +334,74 @@ if [[ $RUNNER_OS == macOS ]]; then
# shellcheck disable=SC2016
echo '#!/bin/bash
attempts=10
i=0
while true; do
hdiutil "$@" && exit 0
rc=$?
if (( ++i >= attempts )); then
echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
exit "$rc"
timeout_seconds=${HDIUTIL_TIMEOUT:-300}
subcommand=$1
# CPACK_DMG_VOLUME_NAME in CMakeLists.txt; a -volname argument wins when there is one.
volume_name=Cockatrice
arg_count=$#
for (( i = 1; i < arg_count; i++ )); do
if [[ ${!i} == -volname ]] && (( i + 1 <= arg_count )); then
(( i++ ))
volume_name=${!i}
fi
echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
sleep 1
done' >"$hdiutil_script"
done
# 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"
flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script")
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
# MSBuild builds a single node: one project at a time, whatever MTT does inside
# it. Ask for a node per core.
if [[ ! $BUILD_PARALLEL_LEVEL ]]; then
if [[ -z $BUILD_PARALLEL_LEVEL ]]; then
BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
fi
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
@ -363,19 +437,20 @@ elif [[ $RUNNER_OS == Windows ]]; then
fi
fi
if [[ $USE_CCACHE ]]; then
if [[ ${USE_CCACHE:-} == "1" ]]; then
echo "::group::Show ccache stats"
ccachestatsverbose
echo "::endgroup::"
fi
echo "::group::Configure cmake"
# Configure CMake
echo "::group::Generate build system"
cmake --version
echo "Running cmake with flags: ${flags[*]}"
echo "Running CMake configuration with the following flags: ${flags[*]}"
cmake .. "${flags[@]}"
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
# 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
@ -390,9 +465,10 @@ if [[ $USE_CCACHE ]]; then
ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime
fi
# Build
echo "::group::Build project"
cpuinfo
echo "Running cmake --build with flags: ${buildflags[*]}"
echo "Running CMake with the following build flags: ${buildflags[*]}"
cmake --build . "${buildflags[@]}"
echo "::endgroup::"
@ -404,16 +480,16 @@ if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
echo "::endgroup::"
fi
if [[ $USE_CCACHE ]]; then
if [[ $CCACHE_EVICTION_AGE ]]; then
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
if [[ ${USE_CCACHE:-} == "1" ]]; then
if [[ -n $CCACHE_EVICTION_AGE ]]; then
echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE"
ccache --evict-older-than "$CCACHE_EVICTION_AGE"
echo "::endgroup::"
fi
echo "::group::Show ccache stats again"
ccachestatsverbose
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!"
fi
@ -430,9 +506,9 @@ if [[ $RUNNER_OS == macOS ]]; then
echo "::endgroup::"
fi
if [[ $MAKE_TEST ]]; then
if [[ ${MAKE_TEST:-} == "1" ]]; then
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
# 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
@ -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
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
ctest --version
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
echo "::endgroup::"
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
# and skipped rather than failing an otherwise green build.
echo "::group::Coverage report"
@ -479,7 +556,7 @@ if [[ $MAKE_COVERAGE ]]; then
echo "::endgroup::"
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"
# 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
@ -679,18 +756,21 @@ POWERSHELL
echo "::endgroup::"
fi
if [[ $MAKE_INSTALL ]]; then
# Install
if [[ ${MAKE_INSTALL:-} == "1" ]]; then
echo "::group::Install"
cmake --build . --target install --config "$BUILDTYPE"
echo "::endgroup::"
fi
if [[ $MAKE_PACKAGE ]]; then
# Package
if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
echo "::group::Create package"
cpack --version
cmake --build . --target package --config "$BUILDTYPE"
echo "::endgroup::"
if [[ $PACKAGE_SUFFIX ]]; then
if [[ -n $PACKAGE_SUFFIX ]]; then
echo "::group::Update package name"
cd ..
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"

View file

@ -181,6 +181,11 @@ function RUN ()
if [[ ${WITH_CRASH_REPORTING:-} ]]; then
args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
fi
# Same reason: the DWARF level reaches compile.sh only if it is handed to the container,
# and without it CI would cache objects twice the size its bounds were measured for.
if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then
args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}")
fi
# shellcheck disable=2086
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
return $?

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>Debian 13</kbd> <sub><i>Trixie</i></sub>
• <kbd>Debian 12</kbd> <sub><i>Bookworm</i></sub>
• <kbd>Fedora 45</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>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/
.tx/
cockatrice/
doc/
docs/
oracle/
Dockerfile

View file

@ -15,8 +15,11 @@ updates:
# Check for updates once a month
schedule:
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)
open-pull-requests-limit: 2
open-pull-requests-limit: 3
# # Enable version updates for Docker
# 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
# schedule:
# 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)
# open-pull-requests-limit: 1
@ -37,5 +43,8 @@ updates:
# Check for updates to GitHub Actions once a week
schedule:
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)
open-pull-requests-limit: 2
open-pull-requests-limit: 5

View file

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

View file

@ -15,7 +15,7 @@ on:
- '!**.md'
- '!.github/**'
- '!.tx/**'
- '!doc/**'
- '!docs/**'
- '.github/workflows/desktop-build.yml'
- 'CMakeLists.txt'
- 'vcpkg.json'
@ -28,7 +28,7 @@ on:
- '!**.md'
- '!.github/**'
- '!.tx/**'
- '!doc/**'
- '!docs/**'
- '.github/workflows/desktop-build.yml'
- 'CMakeLists.txt'
- 'vcpkg.json'
@ -47,6 +47,20 @@ concurrency:
group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
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:
configure:
name: Configure
@ -77,7 +91,7 @@ jobs:
- name: "Checkout"
if: steps.configure.outputs.tag != null
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # fetch all history for all branches and tags
@ -89,7 +103,7 @@ jobs:
TAG: ${{ steps.configure.outputs.tag }}
run: .ci/prep_release.sh
- name: "Create release"
- name: "Create or update draft release"
if: steps.configure.outputs.tag != null
id: create_release
shell: bash
@ -101,20 +115,42 @@ jobs:
body_path: ${{ steps.prepare.outputs.body_path }}
prerelease: ${{ steps.prepare.outputs.is_beta }}
run: |
args=()
[[ $prerelease == yes ]] && args+=(--prerelease)
# A release for this tag may already exist: a maintainer can save a draft in
# 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[@]}" \
--target "$target" \
--title "$release_name" \
--notes-file "$body_path"
if [[ -z "$release_state" ]]; then
args=()
[[ $prerelease == yes ]] && args+=(--prerelease)
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:
name: vcpkg baseline
runs-on: ubuntu-slim
steps:
- 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
# `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
@ -149,7 +185,8 @@ jobs:
allow-failure: yes
package: skip # We are packaged in Arch already
debug_test: yes # see the note on Fedora 44 below
debug_test: yes # see the note on Fedora 45 below
ccache_size: 850M # Debug plus the suite: 680 MB working set at -g1
- distro: Servatrice_Debian
version: 12
@ -157,46 +194,47 @@ jobs:
package: DEB
server_only: yes
test: skip
ccache_size: 400M # server only, no client to compile
# 4.7 min and the server alone: nothing here for a compiler cache to save.
persist_ccache: 'false'
- distro: Debian
version: 13
package: DEB
ccache_size: 850M # the Release build plus the suite it runs
ccache_size: 800M # Release plus the suite: 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
version: 44
version: 45
variant: release # separate image and ccache from the debug leg above
package: RPM
# Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions
# in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug:
# only the Debug configuration passes -Werror, so these two are the legs where a
# new warning in either compiler still fails the build.
debug_test: yes
ccache_size: 900M # Debug for the suite and Release for the package
test: skip # The debug leg above runs this distro's suite
ccache_size: 800M # Release plus the package: 614 MB of objects at -g1
- distro: Ubuntu
version: 26.04
package: DEB
test: skip # The sanitizer leg below runs this distro's suite
# The one leg that runs the suite under AddressSanitizer and
# UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the
# uninstrumented suite, so nothing is lost by instrumenting this one.
#
# It packages nothing. A sanitized Debug build is not something to ship, and
# carrying the package build here meant 18 minutes of Release compile queued
# behind the instrumented one in a single job. The leg above publishes this
# distro's DEB.
- distro: Ubuntu
version: 26.04
variant: sanitize # separate image and ccache, same distro
package: skip
sanitize: yes
ccache_size: 800M # instrumented objects are larger
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
# matrix runs this distro's instrumented suite.
persist_ccache: 'false'
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure
@ -211,25 +249,71 @@ jobs:
CCACHE_EVICTION_AGE: 7d
# The bound is sized against the repository's 10 GB cache quota
# (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy),
# not against what one leg would like. The quota currently holds about 1.1 GB of
# CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers
# (mode=min in docker-release.yml, added here, brings the last one down to roughly
# 0.3 GB - the one input here that is an expectation rather than a measurement, since
# mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five
# nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what
# makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache
# bounds + 3.2G of the fixed entries = 9.65G against the 10G quota.
# not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB
# of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit
# layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry
# per analyzed commit, about 1.07 GB each, of which every one but the newest is dead.
# That is 10.30 GB against the 10 GB quota, so every save past this point costs some
# other entry, and least-recently-used order reaches the buildkit layers before the
# trap caches. prune-caches below keeps only the newest trap cache; the live total is
# 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
# below what a leg writes: ccache spent each build deleting the entries the next run
# asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each
# leg states the size it needs instead: one configuration for most, two for Fedora 44
# (Debug for the suite, Release for the package), the server alone for Servatrice.
# Raising these further means caching fewer legs, since GitHub evicts a whole entry
# when the total is exceeded.
# Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with
# -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit
# rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls;
# the 700M bound on the same leg served 14%, because ccache fills to the limit and then
# evicts the entries the next build is about to ask for. A cache that cannot hold a
# whole configuration therefore buys almost nothing, and the quota goes to fewer legs
# at a size that holds one:
#
# Arch Debug + suite 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.
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
# Debug information dominates a cached object: with the built-in flags one
# configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to
# 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and
# one configuration halves. Empty on tags and on the nightly legs, which ship binaries
# and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and
# is slower, which is accepted because a tag is rare and only pull requests gate.
COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }}
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
@ -242,21 +326,23 @@ jobs:
# also package, so testing and packaging cost one compile instead of two.
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
# Whether this leg restores and saves its compiler cache. Every leg a pull request
# runs keeps one; the nightly-only legs do not, so that the quota is spent on the
# builds that review a change. A tag build runs both sets, and the nightly legs are
# cold there - accepted, because a tag is rare and only the pull-request legs gate.
PERSIST_CCACHE: 'true'
# Whether this leg restores and saves its compiler cache. Not every pull-request leg
# keeps one: a cache only pays for itself when its bound holds a whole configuration
# (see the sizing comment above), and a leg that duplicates another leg's coverage
# spends quota without adding any. The nightly-only legs keep none either. Fedora 45
# states it per leg: its two configurations have their own job and key, so neither
# bound is shared with a build whose objects it would evict.
PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
# Shared with build-linux-nightly so the two leg sets run the same steps.
steps: &linux-steps
- name: "Checkout"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Restore compiler cache (ccache)"
id: restore_ccache
if: env.PERSIST_CCACHE == 'true'
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
path: ${{ env.CACHE }}/.ccache
@ -303,7 +389,7 @@ jobs:
- name: "Upload coverage artifact"
if: matrix.coverage == 'yes'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage-report
path: coverage/
@ -399,6 +485,14 @@ jobs:
# comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
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
- name: "Delete remote compiler cache (ccache)"
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)"
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true'
uses: actions/cache/save@v6
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CACHE }}/.ccache
@ -421,7 +515,7 @@ jobs:
- name: "Upload artifact"
id: upload_artifact
if: matrix.package != 'skip'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
archive: false
if-no-files-found: error
@ -441,7 +535,7 @@ jobs:
- name: "Attest binary provenance"
id: attestation
if: steps.upload_release.outcome == 'success'
uses: actions/attest@v4
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
show-summary: false
subject-path: ${{ steps.build.outputs.path }}
@ -466,7 +560,7 @@ jobs:
test: skip # Running tests on all distros is superfluous
- distro: Fedora
version: 43 # previous release, still covered by the nightly
version: 44 # previous release, still covered by the nightly
package: RPM
test: skip # Running tests on all distros is superfluous
@ -477,6 +571,28 @@ jobs:
package: DEB
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
# 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
@ -501,7 +617,7 @@ jobs:
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure
# A nightly and a tag build cover the legs a pull request or a merge does not wait for:
# the previous release of each distro, and the one instrument that only reports.
# the previous release of each distro, and the instrumented legs.
if: github.event_name == 'schedule' || github.ref_type == 'tag'
runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
@ -514,10 +630,16 @@ jobs:
# A cold nightly build costs minutes on a runner nobody waits for; a cold pull request
# build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir.
PERSIST_CCACHE: 'false'
CCACHE_SIZE: 600M
CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }}
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
# The shared steps below gate on these, so they have to be derived here too. Left
# undefined they silently skipped the build the leg exists for: the coverage leg
# found build/ empty and its report collection failed on every nightly and tag run.
# Same derivation as the pull request job above.
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
steps: *linux-steps
build-vcpkg:
@ -530,6 +652,7 @@ jobs:
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
ccache_eviction_age: 7d
ccache_size: 700M # Release + package: ~0.4 GB at level 0
cmake_generator: Ninja
make_package: 1
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
ccache_eviction_age: 7d
ccache_size: 700M # Release + package: ~0.4 GB at level 0
cmake_generator: Ninja
make_package: 1
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
ccache_eviction_age: 7d
ccache_size: 700M # Debug + suite: ~0.5 GB at level 0
cmake_generator: Ninja
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
@ -589,6 +714,9 @@ jobs:
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
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
# ABI hash covers cl.exe, which is identical, so this leg keeps
# restoring the warm archives. Renaming would orphan them.
@ -602,40 +730,60 @@ jobs:
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
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/
# macOS configurations compile about twice the TUs of a Linux one, so each of these
# three legs gets a bound above what it has been measured to write. The four macOS
# legs used to be bounded at 600M from the same quota as the Linux ones; the three
# pull-request legs now get 700M each while the nightly Intel leg keeps none, and
# that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic).
CCACHE_SIZE: 700M
PERSIST_CCACHE: 'true'
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
# MSVC has no -g level, so there is nothing to ask for on Windows: level 0 is the one
# value with a meaning there, and it drops /Z7 rather than lowering a level. Pull-request
# builds carry no debug information, which is most of what a compiler cache stores; a
# tag is the build whose binary is shipped and symbolicated, so it keeps the full set.
COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '0' || '' }}
# Shared with build-vcpkg-nightly so the two leg sets run the same steps.
steps: &vcpkg-steps
- name: "Checkout"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
- name: "[Windows] Add msbuild to PATH"
if: matrix.os == 'Windows'
uses: microsoft/setup-msbuild@v3
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0
with:
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"
if: matrix.os == 'macOS' && matrix.use_ccache == 1
run: brew install ccache
- name: "[macOS] Restore compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
- name: "Restore compiler cache (ccache)"
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
id: restore_ccache
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }}
key: ccache-${{ matrix.os }}${{ matrix.target }}
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"
if: matrix.os == 'macOS'
run: pipx install aqtinstall
@ -652,17 +800,18 @@ jobs:
- name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}"
if: matrix.os == 'macOS'
id: restore_qt
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
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
# Using jurplel/install-qt-action to install Qt without using brew
# Qt build using vcpkg either just fails or takes too long to build
- name: "[macOS] Install fat Qt ${{ matrix.qt_version }}"
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:
archives: ${{ env.QT_ARCHIVES }}
cache: false
# cache-key-prefix: Qt
dir: ${{ github.workspace }} # thinning script depends on this location
@ -675,17 +824,18 @@ jobs:
- name: "[macOS] Cache thin Qt libraries"
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:
key: ${{ steps.restore_qt.outputs.cache-primary-key }}
path: ${{ github.workspace }}/Qt
- name: "[Windows] Install Qt ${{ matrix.qt_version }}"
if: matrix.os == 'Windows'
uses: jurplel/install-qt-action@v4
uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0
with:
# 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
archives: ${{ env.QT_ARCHIVES }}
cache: ${{ github.ref == 'refs/heads/master' }}
cache-key-prefix: Qt
modules: ${{ matrix.qt_modules }}
@ -694,7 +844,7 @@ jobs:
- name: "[Windows] Install NSIS"
if: matrix.os == 'Windows'
shell: bash
run: choco install nsis
run: choco install nsis --no-progress
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
- name: "[Windows] Set up MSVC environment"
@ -705,7 +855,7 @@ jobs:
- name: "Setup vcpkg cache"
id: vcpkg-cache
uses: TAServers/vcpkg-cache@v3
uses: TAServers/vcpkg-cache@4f07be616a4604dd29f3c6885975cf2c04abee71 # v3.4.2
with:
# Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform,
# target and toolchain. The action restores every entry it finds under the
@ -774,6 +924,21 @@ jobs:
WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
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"
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
@ -950,7 +1115,7 @@ jobs:
- name: "Upload new vcpkg entries"
# An empty count means the step above did not run at all, on master and on forks
if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
if-no-files-found: error # a count above zero with nothing staged is a bug
name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
@ -958,9 +1123,11 @@ jobs:
retention-days: 1
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "[macOS] Delete remote compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
- name: "Delete remote compiler cache (ccache)"
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true
# Bash, not the Windows runner's default pwsh, which cannot parse this `if`.
shell: bash
env:
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }}
@ -969,9 +1136,9 @@ jobs:
echo "Cache deleted successfully"
fi
- name: "[macOS] Cache updated compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
uses: actions/cache/save@v6
- name: "Cache updated compiler cache (ccache)"
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CCACHE_DIR }}
@ -1026,7 +1193,7 @@ jobs:
- name: "Upload artifact"
if: matrix.make_package
id: upload_artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
archive: false
if-no-files-found: error
@ -1058,7 +1225,7 @@ jobs:
# debug identifier, and the PE is what carries the unwind information.
- name: "[Windows] Upload debug symbols (exe + pdb)"
if: matrix.os == 'Windows' && needs.configure.outputs.tag != null
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
if-no-files-found: error
name: ${{ steps.build.outputs.name }}-Symbols
@ -1073,7 +1240,7 @@ jobs:
- name: "Attest binary provenance"
if: steps.upload_release.outcome == 'success'
id: attestation
uses: actions/attest@v4
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
show-summary: false
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
# macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
override_target: 13
@ -1105,7 +1271,6 @@ jobs:
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Intel
type: Release
use_ccache: 1
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
@ -1116,12 +1281,6 @@ jobs:
if: github.event_name == 'schedule' || github.ref_type == 'tag'
runs-on: ${{ matrix.runner }}
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
prune-caches:
@ -1149,6 +1308,16 @@ jobs:
# age is the right test. Dropping a blob an index still lists costs the next image build
# one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the
# entries forever.
#
# 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"
shell: bash
env:
@ -1159,9 +1328,10 @@ jobs:
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
# Must list the ccache key of every leg that keeps a compiler cache
# (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted.
# The nightly legs keep none, so their leftover entries are reclaimed here
# instead of waiting out the age rule.
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27'
# The nightly legs and the pull-request legs that finish under the Windows leg
# keep none, so their leftover entries are reclaimed here instead of waiting out
# the age rule.
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10 ccache-macOS14 ccache-macOS15 ccache-macOS27'
run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
--jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
@ -1169,15 +1339,19 @@ jobs:
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
live=" ${LIVE_VCPKG_SCOPES} "
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
while IFS=$'\t' read -r id ref key accessed; do
[[ -n $id ]] || continue
# Only ever touch the key shapes our workflows create (buildkit-* and index-*
# come from docker-release.yml), so that an entry of some other workflow can
# never be deleted by accident
# come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml),
# so that an entry of some other workflow can never be deleted by accident
case "$key" in
vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;;
vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;;
*) continue ;;
esac
@ -1185,8 +1359,9 @@ jobs:
if [[ $ref != refs/heads/master ]]; then
reason="scoped to $ref"
elif [[ $key == ccache-* ]]; then
# An exact token compare: ccache-Ubuntu26.04 is a prefix of
# ccache-Ubuntu26.04sanitize, and both keys are live
# An exact token compare, because one key can be a prefix of another:
# ccache-Fedora45 is a prefix of ccache-Fedora45debug and
# ccache-Fedora45release, and only the latter two are live.
if [[ $live_ccache != *" $key "* ]]; then
reason="no pull-request leg keeps it"
fi
@ -1197,6 +1372,10 @@ jobs:
if [[ $live != *" $scope "* ]]; then
reason="no matrix leg uses $scope"
fi
elif [[ $key == codeql-trap-* ]]; then
if [[ $key != "$newest_trap" ]]; then
reason="superseded by the newest trap cache"
fi
fi
if [[ -z $reason ]]; then
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"

View file

@ -1,7 +1,7 @@
name: Code Style (C++)
permissions:
contents: read
contents: read # needed to checkout repo
on:
# Push trigger not needed for linting, we do not allow direct pushes to master
@ -12,7 +12,7 @@ on:
- '!.ci/**'
- '!.github/**'
- '!.tx/**'
- '!doc/**'
- '!docs/**'
- '.ci/lint_cpp.sh'
- '.github/workflows/desktop-lint.yml'
- '.clang-format'
@ -21,11 +21,12 @@ on:
jobs:
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:
- name: "Checkout"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 20 # should be enough to find merge base

View file

@ -56,7 +56,7 @@ jobs:
include:
- label: x86
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
platform: linux/arm64
@ -67,17 +67,17 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }}
- 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)"
if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch'
id: login
uses: docker/login-action@v4
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -92,7 +92,7 @@ jobs:
# cache held. The final runtime stage is the part that still pays off.
- name: "Build image"
if: steps.login.outcome != 'success'
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
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), '') }}
@ -104,7 +104,7 @@ jobs:
- name: "Build image and push by digest"
if: steps.login.outcome == 'success'
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
cache-from: type=gha,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 }}
outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true
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
- name: "Export digest"
@ -128,7 +128,7 @@ jobs:
- name: "Upload digest"
if: steps.login.outcome == 'success'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
if-no-files-found: error
name: digest-${{ matrix.label }}
@ -145,14 +145,14 @@ jobs:
steps:
- name: "Download digests"
uses: actions/download-artifact@v8
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: "Login to GitHub Container Registry (GHCR)"
uses: docker/login-action@v4
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -160,7 +160,7 @@ jobs:
- name: "Docker metadata"
id: metadata
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.GHCR_IMAGE }}
flavor: |

View file

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

View file

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

View file

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

View file

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

2
.gitignore vendored
View file

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

2
.gitmodules vendored
View file

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

View file

@ -1,11 +1,11 @@
# Cockatrice's main CMakeLists.txt
#
# This is basically a wrapper to enable/disable the compilation
# of the different projects: servatrice, cockatrice, test
# This is basically a wrapper to enable/disable the compilation of the different projects:
# Cockatrice, Oracle, Servatrice, Test
# This file sets all the variables shared between the projects
# 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)
# Use compiler cache (ccache)
@ -40,24 +40,20 @@ if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/
endif()
# Default to "Release" build type
# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call
if(DEFINED CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE
${CMAKE_BUILD_TYPE}
CACHE STRING "Type of build"
)
else()
# A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE
Release
CACHE STRING "Type of build"
CACHE STRING "Build type"
)
endif()
# ccache does not support MSVC and must not auto-engage on Windows
# (it is installed unintentionally on the Windows CI runner).
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
# also opts out of ccache even though the GNUCXX branch below supports it.
if(USE_CCACHE AND NOT WIN32)
# ccache drives MSVC too (since 4.7), but only when the compiler writes debug
# information into the object file: /Zi and /ZI keep it in a shared PDB, and ccache
# reports them as unsupported rather than risk a stale one. The MSVC flags below
# therefore use /Z7. MSVC precompiled headers are cached as well, with the same
# sloppiness the other toolchains need.
if(USE_CCACHE)
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
# 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.
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
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()
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()
if(WIN32 OR USE_VCPKG)
@ -108,20 +104,21 @@ endif()
# Version can be overriden by git tags, see cmake/getversion.cmake
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)
set(GIT_TAG_RELEASENAME "Reality Check")
endif()
# Use c++20 for all targets
# Use C++20 for all targets
set(CMAKE_CXX_STANDARD
20
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(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true)
set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON)
# Search path for cmake modules
set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake")
@ -134,31 +131,31 @@ include(createversionfile)
# Define a proper install path
if(UNIX)
if(APPLE)
# macOS
# Due to the special bundle structure ignore
# the prefix eventually set by the user.
if(APPLE) # macOS
# Due to the special bundle structure ignore the prefix eventually set by the user
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release)
# Force ccache usage if available
get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
if(RULE_LAUNCH_COMPILE)
message(STATUS "Force enabling CCache usage under macOS")
# Set up wrapper scripts
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")
# Force ccache usage if requested and available
if(USE_CCACHE)
get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
if(RULE_LAUNCH_COMPILE)
message(STATUS "Force enabling ccache usage under macOS")
# 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")
# Set up wrapper scripts
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(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()
else()
# Linux / BSD
else() # Linux / BSD
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
#fix package build
# Fix package build
if(PREFIX)
set(CMAKE_INSTALL_PREFIX ${PREFIX})
else()
@ -166,47 +163,68 @@ if(UNIX)
endif()
endif()
endif()
elseif(WIN32)
elseif(WIN32) # Windows (including 64-bit)
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE})
endif()
# Define proper compilation flags
if(MSVC)
# Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols
set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi")
# Visual Studio: Maximum Optimization, Multi-threaded DLL
set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD")
# Visual Studio: No Optimization, Multi-threaded Debug DLL
# Define compiler flags
if(MSVC) # Microsoft Visual C++ compiler
# /EHsc Enable standard C++ exception handling
# /MP Enable parallel compilation
# /permissive- Enable more standards-conforming behavior
# /utf-8 Set source file encoding and execution character set to UTF-8
# /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")
# 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")
add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING)
elseif(CMAKE_COMPILER_IS_GNUCXX)
# linux/gcc, bsd/gcc, windows/mingw
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler
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)
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror")
else()
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra")
endif()
if(APPLE)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20")
endif()
# -Wcast-align Catch unsafe pointer casts
# -Wmissing-declarations Catch defined functions without a prior declaration
# -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
-Wcast-align
-Wmissing-declarations
-Wno-long-long
-Wno-error=extra
-Wno-error=delete-non-virtual-dtor
-Wno-error=sign-compare
-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})
@ -218,9 +236,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else()
# other: osx/llvm, bsd/llvm
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers
# -O2 Balanced optimization
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)
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter")
else()
@ -229,6 +254,8 @@ else()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else() # Unknown compiler
message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
endif()
# Always included, because they define cockatrice_disable_sanitizers_for() and
@ -238,9 +265,37 @@ include(Sanitizers)
include(Coverage)
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")
add_definitions("-DSFMT_MEXP=19937")
add_compile_definitions(SFMT_MEXP=19937)
endif()
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!")
endif()
#Find OpenSSL
if(WIN32)
# Find OpenSSL
if(WIN32) # Windows (including 64-bit)
find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS})
endif()
endif()
#Find VCredist
# Find Visual C++ Redistributable
if(MSVC)
find_package(VCredistRuntime)
endif()
@ -514,3 +569,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
# Qt6.3+ requires project finalization to support translations
qt_finalize_project()
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
# 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
# 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
# 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 reference definitions. This must be a list of .bib files. The .bib
@ -1091,8 +1091,8 @@ RECURSIVE = YES
EXCLUDE = build/ \
cmake/ \
cmake-build-debug/ \
doc/doxygen/theme/docs/ \
doc/doxygen/theme/include/ \
docs/doxygen/theme/docs/ \
docs/doxygen/theme/include/ \
vcpkg/
# 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
# \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
# 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
# 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
# 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.
# 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
# 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.
# 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
# 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.
# This tag requires that the tag GENERATE_HTML is set to YES.
HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \
doc/doxygen/css/hide_nav_sync.css \
doc/doxygen/css/cockatrice_docs_style.css
HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \
docs/doxygen/css/hide_nav_sync.css \
docs/doxygen/css/cockatrice_docs_style.css
# 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
@ -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.
# 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
# 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
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:
- [![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.
@ -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 Webpage](https://cockatrice.github.io/)
- [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.
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 |
> [!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
> 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_zone_dialog.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/display/background_plate_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/user_interface_settings_page.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/pill_painter.cpp
src/interface/widgets/utility/pill_painter.h
src/interface/widgets/utility/report_utils.cpp
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/visibility_change_listener.cpp
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/tab_edhrec.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_account.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_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp

View file

@ -1,15 +1,16 @@
#include "update_downloader.h"
#include <QByteArray>
#include <QChar>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QIODevice>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QTemporaryDir>
#include <QUrl>
#include <QVariant>
#include <libcockatrice/network/network_manager.h>
#include <utility>
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
{
@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl)
originalUrl = downloadUrl;
}
if (auto *old = std::exchange(response, nullptr)) {
old->abort();
old->deleteLater();
}
errorReported = false;
cancelRequested = false;
// The transfer timeout comes from the manager (NetworkManagerFactory).
response = netMan->get(QNetworkRequest(downloadUrl));
connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished);
connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError);
connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress);
connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort);
connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload);
}
void UpdateDownloader::downloadError(QNetworkReply::NetworkError)
void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError)
{
if (response == nullptr) {
// A reply that was superseded by beginDownload() can still deliver a
// queued error; it must not be reported against the current reply.
if (response == nullptr || sender() != response || errorReported) {
return;
}
emit error(response->errorString().toUtf8());
// A transfer timeout aborts the reply, so it arrives as OperationCanceledError.
// Only an abort the user asked for is suppressed; every other cancel is a
// stalled transfer and has to be reported.
if (networkError == QNetworkReply::OperationCanceledError) {
if (cancelRequested) {
return;
}
errorReported = true;
emit error(tr("The download timed out. Check your connection and try again."));
return;
}
errorReported = true;
emit error(response->errorString());
}
void UpdateDownloader::cancelDownload()
{
cancelRequested = true;
if (response != nullptr) {
response->abort();
}
}
void UpdateDownloader::fileFinished()
{
// If we finished but there's a redirect, follow it
QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute);
if (!redirect.isNull()) {
beginDownload(redirect.toUrl());
// An aborted, superseded reply also emits finished(). Only the reply
// currently stored in response may be consumed.
if (response == nullptr || sender() != response) {
return;
}
// Handle any errors we had
if (response->error()) {
emit error(response->errorString());
// errorOccurred normally reports this first; keep the check for errors that
// only surface when the reply finishes.
if (response->error() != QNetworkReply::NoError) {
downloadError(response->error());
response->deleteLater();
response = nullptr;
return;
}
// Work out the file name of the download
QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1);
// Give the update its own private directory. The name is unpredictable, so a
// local user cannot pre-create or symlink the path, and the original file
// name (including the installer suffix) is preserved inside it.
QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX")));
if (!downloadDir.isValid()) {
emit error(tr("Could not create a temporary directory for the download."));
response->deleteLater();
response = nullptr;
return;
}
downloadDir.setAutoRemove(false);
// Save the build in a temporary directory
QFile file(fileName);
const QString remoteName = QFileInfo(originalUrl.path()).fileName();
const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName);
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
emit error(tr("Could not open the file for reading."));
downloadDir.remove();
emit error(tr("Could not open the file for writing: %1").arg(file.errorString()));
response->deleteLater();
response = nullptr;
return;
}
file.write(response->readAll());
file.close();
const QByteArray data = response->readAll();
if (file.write(data) != data.size()) {
file.cancelWriting();
downloadDir.remove();
emit error(tr("Could not write the downloaded file to disk."));
response->deleteLater();
response = nullptr;
return;
}
if (!file.commit()) {
downloadDir.remove();
emit error(tr("Could not save the downloaded file: %1").arg(file.errorString()));
response->deleteLater();
response = nullptr;
return;
}
response->deleteLater();
response = nullptr;
// Emit the success signal with a URL to the download file
emit downloadSuccessful(QUrl::fromLocalFile(fileName));

View file

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

View file

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

View file

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

View file

@ -294,6 +294,13 @@ void CardItem::drawArrow(const QColor &arrowColor)
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();
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
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);
scene()->addItem(arrow);
pendingArrow = arrow;
arrow->grabMouse();
for (const auto &item : scene()->selectedItems()) {
@ -326,8 +334,14 @@ void CardItem::drawAttachArrow()
return;
}
// See drawArrow(): a repeated trigger must not orphan the previous arrow.
if (pendingArrow) {
pendingArrow->cancel();
}
auto *arrow = new ArrowAttachItem(this);
scene()->addItem(arrow);
pendingArrow = arrow;
arrow->grabMouse();
for (const auto &item : scene()->selectedItems()) {

View file

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

View file

@ -64,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
QString zoneName = zone->getName();
if (zoneName == ZoneNames::TABLE) {
fromStr = tr(" from play");
fromStr = tr("from play");
} else if (zoneName == ZoneNames::GRAVE) {
fromStr = tr(" from their graveyard");
fromStr = tr("from their graveyard");
} else if (zoneName == ZoneNames::EXILE) {
fromStr = tr(" from exile");
fromStr = tr("from exile");
} else if (zoneName == ZoneNames::HAND) {
fromStr = tr(" from their hand");
fromStr = tr("from their hand");
} else if (zoneName == ZoneNames::DECK) {
if (position == 0) {
if (cardName.isEmpty()) {
@ -82,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
cardNameContainsStartZone = true;
} else {
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 {
fromStr = tr(" from the top of their library");
fromStr = tr("from the top of their library");
}
}
} else if (position == zone->getCards().size()) {
@ -97,29 +97,34 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
cardNameContainsStartZone = true;
} else {
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 {
fromStr = tr(" from the bottom of their library");
fromStr = tr("from the bottom of their library");
}
}
} else {
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 {
fromStr = tr(" from their library");
fromStr = tr("from their library");
}
}
} else if (zoneName == ZoneNames::SIDEBOARD) {
fromStr = tr(" from sideboard");
fromStr = tr("from sideboard");
} else if (zoneName == ZoneNames::STACK) {
fromStr = tr(" from the stack");
fromStr = tr("from the stack");
} else {
fromStr = tr(" from custom zone '%1'").arg(zoneName);
fromStr = tr("from custom zone '%1'").arg(zoneName);
}
if (!cardNameContainsStartZone) {
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};
}

View file

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

View file

@ -6,6 +6,9 @@
#include "../player_graphics_item.h"
#include <QAction>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QInputDialog>
#include <QKeySequence>
#include <QList>
#include <QMap>
@ -67,6 +70,15 @@ HandMenu::HandMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(p
aRevealRandomHandCardToAll->setData(-1);
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
mRevealHand->addAction(aRevealHandToAll);
mRevealRandomHandCard->addAction(aRevealRandomHandCardToAll);
@ -146,6 +158,8 @@ void HandMenu::retranslateUi()
mRevealRandomHandCard->setTitle(tr("Reveal r&andom card to..."));
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();
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 onRevealHandTriggered();
void onRevealRandomHandCardTriggered();
void populateRevealRandomHandCardsMenuWithActivePlayers();
void onRevealRandomHandCardsTriggered();
private:
QWidget *dialogParent() const;
PlayerGraphicsItem *player;
QAction *aViewHand = nullptr;
@ -62,6 +65,9 @@ private:
QMenu *mRevealRandomHandCard = nullptr;
QAction *aRevealRandomHandCardToAll = nullptr;
QMenu *mRevealRandomHandCards = nullptr;
QAction *aRevealRandomHandCardsToAll = nullptr;
QMenu *mMoveHandMenu = nullptr;
QAction *aMoveHandToTopLibrary = nullptr;
QAction *aMoveHandToBottomLibrary = nullptr;

View file

@ -10,7 +10,7 @@
#include <libcockatrice/game/player/player_actions.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->setData(cmMoveToTopLibrary);

View file

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

View file

@ -23,6 +23,7 @@
#include <QMenu>
#include <QString>
#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_logic.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,
&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();
retranslateUi();
@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts()
void PlayerMenu::setShortcutsActive()
{
shortcutsActive = true;
if (!isGameStarted()) {
applyLobbyShortcuts();
return;
}
for (auto *c : managedComponents) {
c->setShortcutsActive();
}
@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive()
for (auto *c : managedComponents) {
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();
}

View file

@ -73,17 +73,24 @@ public:
return utilityMenu;
}
/** @brief Whether this player currently holds its shortcuts and the game has started. */
[[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();
/** @brief Unbind keyboard shortcuts. Called when this player loses focus. */
void setShortcutsInactive();
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;
TearOffMenu *playerMenu;
QMenu *countersMenu;

View file

@ -9,7 +9,7 @@
#include <libcockatrice/game/player/player_actions.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();

View file

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

View file

@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive()
aCreateAnotherToken->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:
explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu);
/** @brief Binds only the shortcuts that remain usable before the game starts. */
void setLobbyShortcutsActive();
[[nodiscard]] bool createAnotherTokenActionExists() const
{
return aCreateAnotherToken != nullptr;

View file

@ -1,21 +1,27 @@
#include "view_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../interface/theme_manager.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
#include "../card_dimensions.h"
#include "../z_values.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/filters/filter_string.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
#include "libcockatrice/utility/card_ref.h"
#include <QApplication>
#include <QBrush>
#include <QColor>
#include <QDebug>
#include <QFont>
#include <QFontMetrics>
#include <QMessageLogger>
#include <QObject>
#include <QPainter>
#include <QPalette>
#include <QSharedPointer>
#include <QtGlobal>
#include <QtMath>
@ -34,12 +40,85 @@
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
*/
ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent)
: 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());
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
viewLogic->getOriginalZone()->getViews().append(viewLogic);
@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards()
gridSize = positionCardsForDisplay(cardsToDisplay);
}
updateLibraryEdgeMarker(cardsToDisplay);
// determine bounding rect
qreal aleft = 0;
qreal atop = 0;
@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
lastColumnProp = columnProp;
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->setRealZValue(i);
longestRow = qMax(row, longestRow);
@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
for (int i = 0; i < cardCount; i++) {
auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent());
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->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)
{
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();

View file

@ -9,9 +9,11 @@
#include "select_zone.h"
#include <QGraphicsItem>
#include <QGraphicsLayoutItem>
#include <QList>
#include <QLoggingCategory>
#include <QPointF>
#include <QRectF>
#include <QSizeF>
#include <QString>
@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone");
class Response;
class ServerInfo_Card;
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.
@ -56,6 +82,8 @@ private:
FilterString filterString = FilterString("");
CardList::SortOption groupBy, sortBy;
bool pileView;
LibraryEdgeMarker *libraryEdgeMarker;
QPointF libraryEdgeMarkerAnchorPos;
struct GridSize
{
@ -64,6 +92,9 @@ private:
};
GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort);
void updateLibraryEdgeMarker(const CardList &displayedCards);
void applyLibraryEdgeMarkerText();
void positionLibraryEdgeMarker();
void
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;
@ -79,6 +110,10 @@ public:
{
return optimumRect;
}
/** @brief Re-applies the translated library edge marker text after a language change. */
void retranslateEdgeMarker();
public slots:
void addToViews();
void removeFromViews();

View file

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

View file

@ -1,334 +1,109 @@
#include "dlg_create_game.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/room_commands.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_game.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 <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QMapIterator>
#include <QMessageBox>
#include <QPushButton>
#include <QRadioButton>
#include <QSet>
#include <QSharedPointer>
#include <QSize>
#include <QSpinBox>
#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)
: 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());
descriptionEdit->setText(SettingsCache::instance().game().getGameDescription());
maxPlayersEdit->setValue(SettingsCache::instance().game().getMaxPlayers());
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) {
onlyBuddiesCheckBox->setChecked(SettingsCache::instance().game().getOnlyBuddies());
onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered());
form = new CreateGameFormWidget(_gameTypes, registered, judge, this);
const bool rememberSettings = SettingsCache::instance().game().getRememberGameSettings();
form->setRememberSettingsChecked(rememberSettings);
if (rememberSettings) {
form->setOptions(GameCreationOptions::fromSettings(SettingsCache::instance().game(), _gameTypes, registered));
} else {
onlyBuddiesCheckBox->setEnabled(false);
onlyRegisteredCheckBox->setEnabled(false);
form->resetToDefaults();
}
spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsAllowed());
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());
form->focusDescription();
if (!rememberGameSettings->isChecked()) {
actReset();
}
errorLabel = new QLabel(this);
errorLabel->setObjectName("createGameErrorLabel");
errorLabel->setWordWrap(true);
errorLabel->setVisible(false);
descriptionEdit->setFocus();
clearButton = new QPushButton(tr("&Clear"));
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject);
clearButton = new QPushButton(tr("&Clear"), this);
buttonBox->addButton(QDialogButtonBox::Cancel);
buttonBox->addButton(clearButton, QDialogButtonBox::ActionRole);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::actOK);
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"));
}
DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QString> &_gameTypes, QWidget *parent)
: QDialog(parent), room(0), gameTypes(_gameTypes)
: QDialog(parent), room(nullptr)
{
sharedCtor();
rememberGameSettings->setEnabled(false);
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()));
}
form = new CreateGameFormWidget(_gameTypes, false, false, this);
form->setOptions(GameCreationOptions::fromGameInfo(gameInfo, _gameTypes));
form->setReadOnly(true);
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::accept);
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(form);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
setWindowTitle(tr("Game information"));
}
void DlgCreateGame::actReset()
{
descriptionEdit->setText("");
maxPlayersEdit->setValue(2);
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();
form->resetToDefaults();
clearError();
}
void DlgCreateGame::actOK()
{
Command_CreateGame cmd;
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());
clearError();
auto _gameTypes = QString();
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
while (gameTypeCheckBoxIterator.hasNext()) {
gameTypeCheckBoxIterator.next();
if (gameTypeCheckBoxIterator.value()->isChecked()) {
cmd.add_game_type_ids(gameTypeCheckBoxIterator.key());
_gameTypes += gameTypeCheckBoxIterator.value()->text() + ", ";
}
const GameCreationOptions options = form->options();
Command_CreateGame cmd = options.toCreateGameCommand();
const bool rememberSettings = form->isRememberSettingsChecked();
auto &gameSettings = SettingsCache::instance().game();
gameSettings.setRememberGameSettings(rememberSettings);
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);
connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse);
room->sendRoomCommand(pend);
@ -343,14 +118,26 @@ void DlgCreateGame::checkResponse(const Response &response)
if (response.response_code() == Response::RespOk) {
accept();
} else {
QMessageBox::critical(this, tr("Error"), tr("Server error."));
return;
showError(tr("Server error."));
}
}
void DlgCreateGame::spectatorsAllowedChanged(QT_STATE_CHANGED_T state)
void DlgCreateGame::showError(const QString &message)
{
spectatorsNeedPasswordCheckBox->setEnabled(state);
spectatorsCanTalkCheckBox->setEnabled(state);
spectatorsSeeEverythingCheckBox->setEnabled(state);
if (!errorLabel) {
return;
}
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
* @ingroup RoomDialogs
*/
//! \todo Document this file.
#ifndef DLG_CREATEGAME_H
#define DLG_CREATEGAME_H
@ -10,21 +9,15 @@
#include <QDialog>
#include <QMap>
#include <QString>
#include <libcockatrice/utility/macros.h>
#include <qtmetamacros.h>
class QCheckBox;
class CreateGameFormWidget;
class QDialogButtonBox;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QRadioButton;
class QSpinBox;
class Response;
class ServerInfo_Game;
class TabRoom;
class QWidget;
class DlgCreateGame : public QDialog
{
@ -36,27 +29,16 @@ private slots:
void actOK();
void actReset();
void checkResponse(const Response &response);
void spectatorsAllowedChanged(QT_STATE_CHANGED_T state);
private:
TabRoom *room;
QMap<int, QString> gameTypes;
QMap<int, QRadioButton *> gameTypeCheckBoxes;
CreateGameFormWidget *form = nullptr;
QDialogButtonBox *buttonBox = nullptr;
QPushButton *clearButton = nullptr;
QLabel *errorLabel = nullptr;
QGroupBox *generalGroupBox, *spectatorsGroupBox, *gameSetupOptionsGroupBox;
QLabel *descriptionLabel, *passwordLabel, *maxPlayersLabel, *startingLifeTotalLabel, *shareDecklistsOnLoadLabel;
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();
void showError(const QString &message);
void clearError();
};
#endif
#endif // DLG_CREATEGAME_H

View file

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

View file

@ -230,7 +230,8 @@ void DlgMyReports::onSelectionChanged()
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
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");
commentButton->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 <QDebug>
#include <QHBoxLayout>
#include <QHideEvent>
#include <QList>
#include <QMap>
#include <QScrollArea>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSizePolicy>
#include <QShowEvent>
#include <QString>
#include <QTimer>
#include <QVBoxLayout>
@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck
setLayout(layout);
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);
@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi()
void PrintingSelector::printingsInDeckChanged()
{
if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) {
// Delay the update to avoid race conditions
QTimer::singleShot(100, this, &PrintingSelector::updateDisplay);
displayDirty = true;
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).
*/
@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts()
*/
void PrintingSelector::updateDisplay()
{
deckChangeTimer->stop();
displayDirty = false;
widgetLoadingBufferTimer->stop();
widgetLoadingBufferTimer->deleteLater();
widgetLoadingBufferTimer = new QTimer(this);
@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay()
*/
void PrintingSelector::setCard(const CardInfoPtr &newCard)
{
if (newCard.isNull()) {
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()) {
// Same-name cards may be different database snapshots after a reload.
if (selectedCard == newCard) {
return;
}
widgetLoadingBufferTimer->stop();
deckChangeTimer->stop();
selectedCard = newCard;
if (isVisible()) {
updateDisplay();
} else {
displayDirty = true;
}
flowWidget->setMinimumSizeToMaxSizeHint();
flowWidget->scrollArea->verticalScrollBar()->setValue(0);

View file

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

View file

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

View file

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

View file

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

View file

@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
});
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.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency());
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
@ -675,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi()
}
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"));
tableGroupBox->setTitle(tr("Table grid layout"));

View file

@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage()
auto *chatGrid = new QGridLayout;
chatGrid->addWidget(&chatMentionCheckBox, 0, 0);
chatGrid->addWidget(&invertMentionForeground, 0, 1);
chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight);
chatGrid->addWidget(mentionColor, 0, 2);
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0);
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0);
chatGrid->addWidget(&hexLabel, 1, 2);
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0);
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0);
chatGrid->addWidget(&messagePopups, 5, 0);
chatGrid->addWidget(&mentionPopups, 6, 0);
chatGrid->addWidget(&roomHistory, 7, 0);
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0);
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0);
chatGrid->addWidget(&invertMentionForeground, 0, 3);
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1);
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1);
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1);
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1);
chatGrid->addWidget(&messagePopups, 5, 0, 1, -1);
chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1);
chatGrid->addWidget(&roomHistory, 7, 0, 1, -1);
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1);
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->setLayout(chatGrid);
@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor);
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(&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);
highlightGroupBox = new QGroupBox;
highlightGroupBox->setLayout(highlightNotice);
@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage()
auto *channelGrid = new QGridLayout;
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
channelGrid->addWidget(channelUnreadColor, 0, 1);
channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2);
channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
channelGrid->addWidget(channelMentionColor, 1, 1);
channelGrid->addWidget(&hexChannelMentionLabel, 1, 2);
channelGroupBox = new QGroupBox;
channelGroupBox->setLayout(channelGrid);
@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi()
channelGroupBox->setTitle(tr("Room channel tabs"));
channelUnreadColorLabel.setText(tr("Unread 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"));
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
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"));
invertMentionForeground.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"));
mentionPopups.setText(tr("Enable desktop notification for mentions"));
roomHistory.setText(tr("Enable room message history on join"));
ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
hexLabel.setText(tr("(Color is hexadecimal)"));
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)"));
hexChannelMentionLabel.setText(tr("(Color is hexadecimal)"));
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
aAdd->setText(tr("Add New Message"));

View file

@ -58,13 +58,11 @@ private:
QLineEdit *channelUnreadColor;
QLineEdit *channelMentionColor;
QLineEdit *customAlertString;
QLabel hexLabel;
QLabel hexHighlightLabel;
QLabel channelUnreadColorLabel;
QLabel channelMentionColorLabel;
QLabel hexChannelUnreadLabel;
QLabel hexChannelMentionLabel;
QLabel customAlertStringLabel;
QLabel mentionColorStringLabel;
QLabel highlightColorStringLabel;
QLabel explainMessagesLabel;
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
{
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

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()
{
if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) {
delete aCardMenu->menu();
}
for (auto &player : game->getPlayerManager()->getPlayers()) {
player->clear();
}
@ -570,8 +573,10 @@ void TabGame::adminLockChanged(bool lock)
void TabGame::actGameInfo()
{
DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
dlg.exec();
// Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object.
auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
}
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.
*
* 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)
{
if (!aCardMenu) {
if (!aCardMenu || !emptyCardMenu) {
if (menu) {
menu->deleteLater();
}
return;
}
@ -1169,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu)
gameView->viewport()->update();
}
}
if (menu) {
aCardMenu->setMenu(menu);
} else {
aCardMenu->setMenu(new QMenu);
QMenu *previousMenu = aCardMenu->menu();
aCardMenu->setMenu(menu ? menu : emptyCardMenu);
if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) {
previousMenu->deleteLater();
}
}
@ -1256,7 +1268,8 @@ void TabGame::createMenuItems()
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.
updateInviteButtonState();
@ -1288,6 +1301,7 @@ void TabGame::createReplayMenuItems()
gameMenu->addAction(aLeaveGame);
aCardMenu = nullptr;
emptyCardMenu = nullptr;
addTabMenu(gameMenu);
}

View file

@ -101,6 +101,8 @@ private:
QAction *aInviteToGame = nullptr;
QList<QAction *> phaseActions;
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

View file

@ -1,11 +1,14 @@
#include "tab_moderation.h"
#include "../utility/epoch_format.h"
#include "../utility/table_activation.h"
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/admin_commands.pb.h"
#include "libcockatrice/protocol/pb/response.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_session.pb.h"
#include "tab_supervisor.h"
#include <QAbstractItemView>
#include <QDateTime>
@ -21,6 +24,7 @@
#include <QSharedPointer>
#include <QSplitter>
#include <QStringList>
#include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextEdit>
@ -99,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
topBar->addWidget(removeBannerButton);
userInfoGroup = new QGroupBox;
userInfoNameLabel = new QLabel;
userInfoNameValue = new QLabel;
userInfoRegisteredLabel = new QLabel;
userInfoRegisteredValue = new QLabel;
userInfoLastLoginLabel = new QLabel;
userInfoLastLoginValue = new QLabel;
userInfoStatusLabel = new QLabel;
QFont displayFont = userInfoNameValue->font();
displayFont.setPointSize(displayFont.pointSize() + 6);
displayFont.setBold(true);
userInfoNameValue->setFont(displayFont);
userInfoStatusValue = new QLabel;
userInfoCountsLabel = new QLabel;
userInfoCountsValue = new QLabel;
QFont statusFont = userInfoStatusValue->font();
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;
userInfoNotesEdit = new QTextEdit;
userInfoNotesEdit->setReadOnly(true);
auto *infoGrid = new QGridLayout;
infoGrid->addWidget(userInfoNameLabel, 0, 0);
infoGrid->addWidget(userInfoNameValue, 0, 1);
infoGrid->addWidget(userInfoRegisteredLabel, 0, 2);
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 *identityRow = new QHBoxLayout;
identityRow->addWidget(userInfoNameValue);
identityRow->addStretch();
identityRow->addWidget(userInfoStatusValue);
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) {
table->setSelectionBehavior(QAbstractItemView::SelectRows);
@ -142,19 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
table->horizontalHeader()->setStretchLastSection(true);
};
altsGroup = new QGroupBox;
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
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);
sessionsGroup = new QGroupBox;
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
configureTable(sessionsTable);
auto *sessionsLayout = new QVBoxLayout(sessionsGroup);
auto *sessionsPage = new QWidget;
auto *sessionsLayout = new QVBoxLayout(sessionsPage);
sessionsLayout->addWidget(sessionsTable);
staffGroup = new QGroupBox;
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
configureTable(staffTable);
refreshStaffButton = new QPushButton;
@ -162,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
auto *staffHeader = new QHBoxLayout;
staffHeader->addStretch();
staffHeader->addWidget(refreshStaffButton);
auto *staffLayout = new QVBoxLayout(staffGroup);
auto *staffPage = new QWidget;
auto *staffLayout = new QVBoxLayout(staffPage);
staffLayout->addWidget(staffTable);
staffLayout->addLayout(staffHeader);
sectionTabs = new QTabWidget;
sectionTabs->addTab(altsPage, QString());
sectionTabs->addTab(sessionsPage, QString());
sectionTabs->addTab(staffPage, QString());
auto *splitter = new QSplitter(Qt::Vertical);
splitter->addWidget(userInfoGroup);
splitter->addWidget(altsGroup);
splitter->addWidget(sessionsGroup);
splitter->addWidget(staffGroup);
splitter->addWidget(sectionTabs);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 2);
splitter->setStretchFactor(2, 2);
splitter->setStretchFactor(3, 1);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
@ -196,45 +239,56 @@ void TabModeration::retranslateUi()
refreshStaffButton->setText(tr("Refresh"));
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:"));
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"));
sessionsGroup->setTitle(tr("Sessions"));
staffGroup->setTitle(tr("Staff Last Logins"));
sectionTabs->setTabText(0, tr("Alts"));
sectionTabs->setTabText(1, tr("Sessions"));
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"),
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")});
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
}
QString TabModeration::formatEpoch(quint64 ts) const
{
if (ts == 0) {
return tr("Unknown");
}
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm");
return formatEpochTimestamp(ts, tr("Unknown"));
}
void TabModeration::clearUserData()
{
currentUser.clear();
cachedUserInfoValid = false;
cachedAltsCount = -1;
userInfoNameValue->clear();
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->clear();
userInfoCountsValue->clear();
sublineLabel->clear();
cardReportsValue->setText(QStringLiteral("–"));
cardWarnsValue->setText(QStringLiteral("–"));
cardBansValue->setText(QStringLiteral("–"));
altsCountValue->setText(QStringLiteral("–"));
userInfoNotesEdit->clear();
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
resetPasswordButton->setEnabled(false);
removeAvatarButton->setEnabled(false);
removeBannerButton->setEnabled(false);
openAltsButton->setEnabled(false);
}
void TabModeration::investigate(const QString &userName)
@ -253,9 +307,13 @@ void TabModeration::investigateUser()
return;
}
currentUser = userName;
cachedUserInfoValid = false;
cachedAltsCount = -1;
resetPasswordButton->setEnabled(true);
removeAvatarButton->setEnabled(true);
removeBannerButton->setEnabled(true);
openAltsButton->setEnabled(true);
altsCountValue->setText(QStringLiteral("…"));
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
requestUserInfo(userName);
@ -263,13 +321,22 @@ void TabModeration::investigateUser()
requestAlts(userName);
}
void TabModeration::openAltsGraph()
{
if (currentUser.isEmpty()) {
return;
}
getTabSupervisor()->openTabAlts(currentUser);
}
void TabModeration::requestUserInfo(const QString &userName)
{
userInfoNameValue->setText(userName);
userInfoRegisteredValue->setText(tr("Loading..."));
userInfoLastLoginValue->setText(tr("Loading..."));
sublineLabel->setText(tr("Loading..."));
userInfoStatusValue->clear();
userInfoCountsValue->clear();
cardReportsValue->setText(QStringLiteral("…"));
cardWarnsValue->setText(QStringLiteral("…"));
cardBansValue->setText(QStringLiteral("…"));
userInfoNotesEdit->clear();
Command_ReportUserInfo cmd;
@ -282,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName)
void TabModeration::userInfoResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
cachedUserInfoValid = false;
sublineLabel->clear();
userInfoStatusValue->setText(tr("Error loading user info."));
cardReportsValue->setText(QStringLiteral("–"));
cardWarnsValue->setText(QStringLiteral("–"));
cardBansValue->setText(QStringLiteral("–"));
return;
}
@ -293,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response)
return;
}
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time()));
userInfoLastLoginValue->setText(formatEpoch(resp.last_login()));
cachedUserInfo.CopyFrom(resp);
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;
statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
if (resp.has_is_admin() && resp.is_admin()) {
statusParts << tr("admin");
}
userInfoStatusValue->setText(statusParts.join(", "));
userInfoStatusValue->setText(statusParts.join(QStringLiteral(", ")));
userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
.arg(resp.total_reports())
.arg(resp.total_bans())
.arg(resp.total_warns()));
cardReportsValue->setText(QString::number(resp.total_reports()));
cardWarnsValue->setText(QString::number(resp.total_warns()));
cardBansValue->setText(QString::number(resp.total_bans()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
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)
{
Command_GetUserSessions cmd;
cmd.set_user_name(userName.toStdString());
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);
}
@ -352,7 +439,11 @@ void TabModeration::requestAlts(const QString &userName)
Command_GetUserAlts cmd;
cmd.set_user_name(userName.toStdString());
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);
}
@ -360,10 +451,21 @@ void TabModeration::altsResponse(const Response &response)
{
altsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
cachedAltsCount = -1;
altsCountValue->setText(QStringLiteral("–"));
return;
}
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());
for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i);

View file

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

View file

@ -5,6 +5,7 @@
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "report_status_delegate.h"
#include "tab_supervisor.h"
#include <QAbstractItemView>
@ -24,7 +25,7 @@
#include <QPushButton>
#include <QSharedPointer>
#include <QSignalBlocker>
#include <QSplitter>
#include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextCursor>
@ -126,26 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
table->setContextMenuPolicy(Qt::CustomContextMenu);
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table));
userContextMenu = new UserContextMenu(tabSupervisor, this);
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->setReadOnly(true);
auto *descLayout = new QVBoxLayout(descGroup);
auto *descLayout = new QVBoxLayout(descPage);
descLayout->setContentsMargins(4, 4, 4, 4);
descLayout->addWidget(descriptionEdit);
chatGroup = new QGroupBox;
auto *chatPage = new QWidget;
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
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->addWidget(chatLogEdit);
commentsGroup = new QGroupBox;
auto *commentsPage = new QWidget;
commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true);
auto *commentInputRow = new QHBoxLayout;
@ -156,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
commentInputRow->addWidget(commentInput);
commentInputRow->addWidget(commentButton);
auto *commentsLayout = new QVBoxLayout(commentsGroup);
auto *commentsLayout = new QVBoxLayout(commentsPage);
commentsLayout->setContentsMargins(4, 4, 4, 4);
commentsLayout->addWidget(commentsEdit);
commentsLayout->addLayout(commentInputRow);
userContextGroup = new QGroupBox;
auto *userPage = new QWidget;
userContextName = new QLabel;
userContextAccountAge = new QLabel;
userContextReports = new QLabel;
@ -180,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
userContextWarnsLabel = new QLabel;
userContextNotesLabel = new QLabel;
userContextRecentReportsLabel = new QLabel;
auto *ucLayout = new QGridLayout(userContextGroup);
auto *ucLayout = new QGridLayout(userPage);
ucLayout->setContentsMargins(8, 8, 8, 8);
ucLayout->addWidget(userContextUserLabel, 0, 0);
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(userContextRecentReportsLabel, 4, 0, 1, 4);
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
userContextGroup->setVisible(false);
statsGroup = new QGroupBox;
statsGroup->setCheckable(true);
@ -223,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
}
});
detailSplitter = new QSplitter(Qt::Vertical);
detailSplitter->addWidget(descGroup);
detailSplitter->addWidget(chatGroup);
detailSplitter->addWidget(commentsGroup);
detailSplitter->setStretchFactor(0, 1);
detailSplitter->setStretchFactor(1, 1);
detailSplitter->setStretchFactor(2, 2);
detailTabs = new QTabWidget;
detailTabs->addTab(descPage, QString());
detailTabs->addTab(chatPage, QString());
detailTabs->addTab(commentsPage, QString());
detailTabs->addTab(userPage, QString());
assignButton = new QPushButton;
resolveButton = new QPushButton;
@ -261,12 +276,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
layout->addLayout(topBar);
layout->addWidget(statsLabel);
layout->addWidget(table, 2);
layout->addWidget(detailSplitter, 1);
layout->addWidget(userContextGroup);
layout->addLayout(reportIdentityRow);
layout->addWidget(reportHeaderSubline);
layout->addWidget(detailTabs, 2);
layout->addWidget(statsGroup);
layout->addLayout(actionBar);
retranslateUi();
clearReportHeader();
setActionsEnabled(false);
refreshList();
@ -293,18 +310,18 @@ void TabReport::retranslateUi()
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
descGroup->setTitle(tr("Description"));
chatGroup->setTitle(tr("Chat Log Context"));
chatGroup->setToolTip(
tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited."));
commentsGroup->setTitle(tr("Comments / Thread"));
detailTabs->setTabText(0, tr("Description"));
detailTabs->setTabText(1, tr("Chat Log Context"));
detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be "
"incomplete or edited."));
detailTabs->setTabText(2, tr("Comments / Thread"));
detailTabs->setTabText(3, tr("Reported User"));
descriptionEdit->setPlaceholderText(tr("No description."));
chatLogEdit->setPlaceholderText(tr("No chat log attached."));
commentsEdit->setPlaceholderText(tr("No comments yet."));
commentInput->setPlaceholderText(tr("Type a reply..."));
commentButton->setText(tr("Send"));
userContextGroup->setTitle(tr("Reported User Context"));
userContextUserLabel->setText(tr("User:"));
userContextAgeLabel->setText(tr("Account Age:"));
userContextReportsLabel->setText(tr("Reports:"));
@ -312,10 +329,18 @@ void TabReport::retranslateUi()
userContextWarnsLabel->setText(tr("Warns:"));
userContextNotesLabel->setText(tr("Admin Notes:"));
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
userContextAccountAge->setText(QString());
userContextReports->setText(QString());
userContextBans->setText(QString());
userContextWarns->setText(QString());
if (headerReportId >= 0) {
for (const ServerInfo_Report &r : filteredReports) {
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"));
@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response)
chatLogEdit->clear();
commentsEdit->clear();
setActionsEnabled(false);
userContextGroup->setVisible(false);
clearReportHeader();
clearUserContext();
}
}
@ -445,11 +471,14 @@ void TabReport::applyFilters()
table->setSortingEnabled(false);
table->setRowCount(filteredReports.size());
QFont reportedFont = table->font();
reportedFont.setBold(true);
for (int row = 0; row < filteredReports.size(); ++row) {
const ServerInfo_Report &r = filteredReports[row];
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())));
@ -458,12 +487,30 @@ void TabReport::applyFilters()
table->setItem(row, COL_ROOM,
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->resizeColumnsToContents();
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()));
}
@ -490,6 +537,31 @@ void TabReport::updateStats()
.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()
{
const int row = table->currentRow();
@ -500,7 +572,8 @@ void TabReport::onSelectionChanged()
commentInput->clear();
commentButton->setEnabled(false);
setActionsEnabled(false);
userContextGroup->setVisible(false);
clearReportHeader();
clearUserContext();
return;
}
@ -509,6 +582,18 @@ void TabReport::onSelectionChanged()
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)
{
QTableWidgetItem *clickedItem = table->itemAt(pos);
@ -546,12 +631,17 @@ void TabReport::loadReportDetails(int reportId)
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
updateReportHeader(r);
if (headerReportId != reportId) {
headerReportId = reportId;
detailTabs->setCurrentIndex(0);
}
QString reportedUser = QString::fromStdString(r.reported_user_name());
if (!reportedUser.isEmpty()) {
requestUserInfo(reportedUser);
} else {
userContextGroup->setVisible(false);
clearUserContext();
}
QString status = QString::fromStdString(r.status());
@ -871,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response)
void TabReport::requestUserInfo(const QString &userName)
{
if (userName.isEmpty()) {
userContextGroup->setVisible(false);
clearUserContext();
return;
}
lastRequestedUser = userName;
userContextGroup->setVisible(true);
userContextName->setText(userName);
userContextAccountAge->setText(tr("Loading..."));
userContextReports->clear();

View file

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

View file

@ -22,6 +22,7 @@
#include "tab.h"
#include "tab_account.h"
#include "tab_admin.h"
#include "tab_alts.h"
#include "tab_card_art_rules.h"
#include "tab_deck_editor.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),
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(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");
@ -575,6 +576,7 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
tabLog = nullptr;
tabReport = nullptr;
tabModeration = nullptr;
tabAlts = nullptr;
tabDeveloper = nullptr;
isLocalGame = true;
userInfo = new ServerInfo_User;
@ -623,6 +625,9 @@ void TabSupervisor::stop()
if (tabModeration) {
tabModeration->close();
}
if (tabAlts) {
tabAlts->close();
}
if (tabCardArtRules) {
tabCardArtRules->close();
}
@ -931,6 +936,21 @@ void TabSupervisor::openTabModeration(const QString &userName)
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)
{
if (checked && !tabDeveloper) {

View file

@ -50,6 +50,7 @@ class TabAdmin;
class TabMessage;
class TabReport;
class TabModeration;
class TabAlts;
class TabAccount;
class TabDeckEditor;
class TabDeveloper;
@ -116,6 +117,7 @@ private:
TabLog *tabLog;
TabReport *tabReport;
TabModeration *tabModeration;
TabAlts *tabAlts;
TabDeveloper *tabDeveloper;
QMap<int, TabRoom *> roomTabs;
QMap<int, TabGame *> gameTabs;
@ -209,6 +211,7 @@ public slots:
/** Joins or spectates a game listed on a user's profile hover card. */
void joinGameFromUserCard(int gameId, int roomId, bool asSpectator);
void openTabModeration(const QString &userName = {});
void openTabAlts(const QString &userName = {});
void switchToFirstAvailableNetworkTab();
void maximizeMainWindow();
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");
}
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,
int row,
const ServerInfo_Report &report,
@ -89,7 +106,8 @@ void fillReportTableRow(QTableWidget *table,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn)
int assignedColumn,
bool colorizeStatus)
{
auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
idItem->setData(Qt::UserRole, report.report_id());
@ -102,8 +120,12 @@ void fillReportTableRow(QTableWidget *table,
table->setItem(row, gameIdColumn,
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status()));
setReportStatusItemColor(statusItem, QString::fromStdString(report.status()));
const QString rawStatus = 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, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));

View file

@ -20,6 +20,7 @@ QFont reportLogFont();
QString formatReportCategory(const QString &category);
QString formatReportTime(qint64 secondsSinceEpoch);
QString formatReportStatus(const QString &status);
void fillReportTableRow(QTableWidget *table,
int row,
@ -30,7 +31,8 @@ void fillReportTableRow(QTableWidget *table,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn);
int assignedColumn,
bool colorizeStatus = true);
void renderReportDetails(QTextEdit *chatLogEdit,
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 <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
* model.
* This widget provides a graphical control element for the CardFilter::Attr::AttrColor and
* CardFilter::Attr::AttrColorIdentity filters applied to the filter model.
* @param parent The Qt Widget that this widget will parent to
* @param _filterModel The filter model that this widget will manipulate
*/
@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
modeComboBox->blockSignals(true);
modeComboBox->clear();
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes));
modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes));
modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude));
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity));
modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole);
modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole);
modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"),
Qt::ToolTipRole);
modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole);
modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole);
if (currentMode == FilterMode::Custom) {
addCustomFilterModeItem();
}
modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter"));
modeComboBox->setAccessibleName(tr("Color filter mode"));
// Restore current mode
const int index = modeComboBox->findData(QVariant::fromValue(currentMode));
@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
modeComboBox->blockSignals(false);
}
void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode)
{
int index = modeComboBox->findData(QVariant::fromValue(filterMode));
if (index < 0 && filterMode == FilterMode::Custom) {
index = modeComboBox->count();
addCustomFilterModeItem();
}
if (index >= 0) {
modeComboBox->setCurrentIndex(index);
}
}
void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem()
{
int customIndex = modeComboBox->count();
modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom));
modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"),
Qt::ToolTipRole);
}
void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active)
{
if (active) {
@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo
void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color)
{
QString colorString = color;
QString typeStr;
FilterModeInfo info = getFilterModeInfo(currentMode);
// Remove previous filters
removeFilters(filterModel, color, info.attr);
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> matchingFilters;
for (const CardFilter *filter : allColorFilters) {
if (filter->term() == color) {
matchingFilters.append(filter);
}
}
for (const CardFilter *filter : matchingFilters) {
filterModel->removeFilter(filter);
}
// Add actual filter
switch (currentMode) {
case FilterMode::ExactMatch:
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor));
break;
case FilterMode::Includes:
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor));
break;
case FilterMode::IncludeExclude:
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor));
break;
if (info.includeType != CardFilter::Type::TypeEnd) {
filterModel->addFilter(new CardFilter(color, info.includeType, info.attr));
}
}
void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color)
{
QString colorString = color;
FilterModeInfo info = getFilterModeInfo(currentMode);
// Remove inclusion filters
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> matchingFilters;
removeFilters(filterModel, color, info.attr);
for (const CardFilter *filter : allColorFilters) {
if (filter->term() == color) {
matchingFilters.append(filter);
}
}
for (const CardFilter *filter : matchingFilters) {
filterModel->removeFilter(filter);
}
// Add exclusion filters if the mode demands it
switch (currentMode) {
case FilterMode::ExactMatch:
filterModel->addFilter(
new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor));
break;
case FilterMode::IncludeExclude:
filterModel->addFilter(
new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor));
break;
case FilterMode::Includes:
// No exclusion in Includes mode
break;
// Add exclusion filters if the mode demands it.
if (info.excludeType != CardFilter::Type::TypeEnd) {
filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr));
}
}
@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode()
filterModel->filterTree()->blockSignals(true);
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor);
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity);
const QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
@ -211,52 +212,53 @@ QList<QChar> VisualDatabaseDisplayColorFilterWidget::getActiveColors()
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;
for (const CardFilter *filter : allColorFilters) {
if (manaSymbolWidget->getSymbolChar() == filter->term()) {
switch (currentMode) {
case FilterMode::ExactMatch:
switch (filter->type()) {
case CardFilter::Type::TypeAnd:
setManaSymbolActive(filter->term().at(0), true);
break;
default:
setManaSymbolActive(filter->term().at(0), false);
break;
}
break;
case FilterMode::Includes:
switch (filter->type()) {
case CardFilter::Type::TypeOr:
setManaSymbolActive(filter->term().at(0), true);
break;
default:
setManaSymbolActive(filter->term().at(0), false);
break;
}
break;
case FilterMode::IncludeExclude:
switch (filter->type()) {
case CardFilter::Type::TypeOr:
setManaSymbolActive(filter->term().at(0), true);
break;
default:
setManaSymbolActive(filter->term().at(0), false);
break;
}
break;
}
for (const CardFilter *filter : filters) {
if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) {
found = true;
break;
}
}
if (!found) {
setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false);
}
manaSymbolWidget->setColorActive(useExclude ? !found : found);
manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom);
}
}

View file

@ -10,8 +10,8 @@
#include <QByteArray>
#include <QChar>
#include <QList>
#include <QMetaType>
#include <QWidget>
#include <libcockatrice/filters/filter_mode.h>
#include <libcockatrice/utility/debouncer.h>
#include <qtmetamacros.h>
@ -38,15 +38,6 @@ private:
int circleDiameter;
};
enum class FilterMode
{
ExactMatch, // Only selected colors are included, all others are excluded.
Includes, // Include selected colors (OR condition).
IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT).
};
Q_DECLARE_METATYPE(FilterMode)
class VisualDatabaseDisplayColorFilterWidget : public QWidget
{
Q_OBJECT
@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget
public:
explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel);
void retranslateUi();
void setFilterMode(FilterMode filterMode);
signals:
void filterModeChanged(FilterMode filterMode);
private slots:
void addCustomFilterModeItem();
void handleColorToggled(QChar color, bool active);
void addFilter(QChar color);
void removeFilter(QChar color);

View file

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

View file

@ -10,7 +10,7 @@
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QFuture>
#include <QMetaObject>
#include <QMetaType>
#include <QSet>
@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan()
// 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.
auto *watcher = new QFutureWatcher<DeckScanResult>(this);
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] {
watcher->deleteLater();
// The continuation is bound to this model as its context object: it runs on the UI thread,
// and Qt drops it if the model is destroyed first, so no result is delivered to a dead model.
QFuture<DeckScanResult> scan =
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); });
scan.then(this, [this, generation](const DeckScanResult &result) {
if (generation != scanGeneration) {
return; // A newer scan started while this one was running; drop the stale result.
}
const DeckScanResult result = watcher->result();
folderPaths = result.folderPaths;
if (result.decks.isEmpty()) {
@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan()
beginLoad(row);
}
});
watcher->setFuture(
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
}
void VisualDeckStorageModel::beginLoad(int row)
@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row)
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
const int generation = scanGeneration;
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this);
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> {
auto load = QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
if (!deck) {
return std::nullopt;
@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row)
// keep the completion handler on the UI thread cheap.
const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query());
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()

View file

@ -1,11 +1,11 @@
## Building Doxygen Documentation
We use [Doxygen](https://www.doxygen.nl) for our code documentation.
After installing the tool, you can build a HTML webpage into 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
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)
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>
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