Compare commits

..

4 commits

47 changed files with 258 additions and 1742 deletions

View file

@ -4,6 +4,7 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ccache \
clang-format \
cmake \
file \
g++ \

View file

@ -5,6 +5,7 @@ RUN apt-get update && \
build-essential \
ca-certificates \
ccache \
clang-format \
cmake \
file \
g++ \

View file

@ -4,6 +4,7 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ccache \
clang-format \
cmake \
file \
g++ \

View file

@ -4,6 +4,7 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ccache \
clang-format \
cmake \
file \
g++ \

View file

@ -5,6 +5,7 @@ RUN apt-get update && \
build-essential \
ca-certificates \
ccache \
clang-format \
cmake \
file \
g++ \

View file

@ -17,7 +17,7 @@
# --dir <dir> sets the name of the build dir, default is "build"
# --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake
# --target-macos-version <version> sets the min os version - only used for macOS builds
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir>)
# exitcode: 1 for failure, 3 for invalid arguments
@ -243,14 +243,8 @@ if [[ $RUNNER_OS == macOS ]]; 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"
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $TARGET_MACOS_VERSION)" >>"$triplet_file"
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $TARGET_MACOS_VERSION)" >>"$triplet_file"
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")

View file

@ -338,8 +338,6 @@ jobs:
soc: Intel
type: Release
use_ccache: 1
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.3"
- os: macOS
@ -356,9 +354,6 @@ jobs:
soc: Apple
type: Release
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.6"
- os: macOS
@ -375,8 +370,6 @@ jobs:
soc: Apple
type: Release
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: macOS
@ -390,8 +383,6 @@ jobs:
soc: Apple
type: Debug
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: Windows
@ -405,8 +396,6 @@ jobs:
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
type: Release
vcpkg_scope: 'vcpkg/windows10-x64-vs2026/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure
@ -504,28 +493,8 @@ jobs:
id: vcpkg-cache
uses: TAServers/vcpkg-cache@v3
with:
# Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform,
# target and toolchain. The action restores every entry it finds under the
# prefix, not just the hashes vcpkg asks for, so with one shared vcpkg/
# namespace every leg downloads all 66 archives of all 5 legs on every run.
# Each of those restores also refreshes the entry's last access date, which
# is why a dead ABI generation is never evicted by age. Naming the scope is
# what makes those generations identifiable, and prune-caches below able to
# delete them.
prefix: ${{ matrix.vcpkg_scope }}
token: ${{ secrets.GITHUB_TOKEN }}
# The action only creates its directory when the prefix matched at least one
# entry, and it reads that directory unguarded when it saves at the end of the
# job. A scope nobody has written to yet therefore leaves the directory missing
# and fails the job, which is exactly the state every scope starts in. Make the
# directory up front so an empty scope is a cache miss instead of an error.
- name: "Create vcpkg cache directory"
shell: bash
env:
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
run: mkdir -p "$CACHE_DIR"
# Uses environment variables, see compile.sh for more details
- name: "Build Cockatrice"
id: build
@ -545,19 +514,9 @@ jobs:
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
TARGET_MACOS_VERSION: ${{ matrix.override_target }}
USE_CCACHE: ${{ matrix.use_ccache }}
# Only pushes to the default branch may write the shared binary cache: the
# archives a run compiles are only as trustworthy as the code they were
# built from, and only merged code may populate the scope that master and
# release builds restore and link. Pull requests (same-repository ones
# included) restore the shared cache but never write it; with the source
# left at read the collect step below finds nothing on PRs, so no
# pull-request-scoped entry is ever saved that no later run could restore.
# Forks pushing to their own default branch resolve github.ref against
# their own repository, so they keep a working cache of their own there.
VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}"
VCPKG_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph
VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }}
run: .ci/compile.sh --server --test --vcpkg
# CPack reports a failed makensis run as "Problem running NSIS command"
@ -571,63 +530,6 @@ jobs:
shell: bash
run: find . -name NSISOutput.log -exec cat {} +
# A master run hands over the archives it had to build itself so that
# vcpkg-cache-import.yml, which runs in the default branch scope, can save them
# where later runs can restore them. Pull requests hand over nothing: their cache
# entries would be scoped to refs/pull/<n>/merge and stay unreadable for every
# other run, so writing them only burns the repository's quota.
# vcpkg_upload keeps this to one leg per scope: macOS 14, 15 and 26 share a scope
# by design, and all three rebuild the same archives, so uploading from each of them
# would publish three identical 269 MB artifacts under one artifact name and make
# the importer spawn a save job per copy.
- name: "Collect new vcpkg entries"
id: vcpkg_new
if: github.ref == 'refs/heads/master' && matrix.vcpkg_upload == 1
shell: bash
env:
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
GH_TOKEN: ${{ github.token }}
VCPKG_SCOPE: '${{ matrix.vcpkg_scope }}'
run: |
# The archive name is the ABI hash, so a missing key is exactly what vcpkg had
# to compile from source in this job.
gh api --paginate \
"repos/$GITHUB_REPOSITORY/actions/caches?ref=refs/heads/master&key=$VCPKG_SCOPE&per_page=100" \
--jq '.actions_caches[].key' | sort -u >"$RUNNER_TEMP/master-keys.txt"
find "$CACHE_DIR" -type f -name '*.zip' -exec basename {} .zip \; |
sed "s|^|$VCPKG_SCOPE|" | sort -u >"$RUNNER_TEMP/local-keys.txt"
comm -23 "$RUNNER_TEMP/local-keys.txt" "$RUNNER_TEMP/master-keys.txt" >"$RUNNER_TEMP/new-keys.txt"
mkdir -p "$RUNNER_TEMP/vcpkg-new"
: >"$RUNNER_TEMP/vcpkg-new/keys.txt"
while read -r key; do
hash="${key##*/}"
archive="$(find "$CACHE_DIR" -type f -name "$hash.zip" -print -quit)"
dir="$RUNNER_TEMP/vcpkg-new/${hash:0:2}" # vcpkg's own two character fanout
mkdir -p "$dir"
cp "$archive" "$dir/"
echo "$key ${hash:0:2}/$hash.zip" >>"$RUNNER_TEMP/vcpkg-new/keys.txt"
done <"$RUNNER_TEMP/new-keys.txt"
count="$(wc -l <"$RUNNER_TEMP/new-keys.txt")"
echo "vcpkg scope $VCPKG_SCOPE: $count entries to import"
echo "count=$count" >>"$GITHUB_OUTPUT"
echo "slug=$(sed -e 's|^vcpkg/||' -e 's|/$||' -e 's|/|-|g' <<<"$VCPKG_SCOPE")" >>"$GITHUB_OUTPUT"
# archive: false accepts a single file and nothing else, and a scope hands over one
# archive per ABI hash vcpkg had to rebuild, which is 23 files on a cold runner.
# Zipping the directory keeps the fanout at the artifact root, which is where
# vcpkg-cache-import.yml looks for keys.txt once download-artifact unpacks it.
- name: "Upload new vcpkg entries"
# An empty count means the step above did not run at all, on master and on forks
if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
uses: actions/upload-artifact@v7
with:
if-no-files-found: error # a count above zero with nothing staged is a bug
name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
path: ${{ runner.temp }}/vcpkg-new
retention-days: 1
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "[macOS] Delete remote compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
@ -740,67 +642,3 @@ jobs:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
prune-caches:
name: Prune caches
# Only master writes caches at all, so only master may clean them up too
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
continue-on-error: true # freeing space must never fail a build
steps:
# The repository holds 10Gi of cache against entries that want more, and GitHub
# evicts the least recently used entry whenever that is exceeded. The vcpkg
# entries are the reason that never happens: the restore above touches every
# entry under the prefix on every run, so a dead ABI generation keeps its access
# date fresh forever and age based eviction can never reach it. Deleting by age
# alone cannot fix that, only knowing which scope an entry belongs to can.
- name: "Delete unusable cache entries"
shell: bash
env:
GH_TOKEN: ${{ github.token }}
# Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of
# a scope that is still in use get deleted on the next run.
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
--jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
live=" ${LIVE_VCPKG_SCOPES} "
deleted=0
while IFS=$'\t' read -r id ref key accessed; do
[[ -n $id ]] || continue
# Only ever touch the key shapes this workflow creates, so that an entry of
# some other workflow can never be deleted by accident
case "$key" in
vcpkg/* | ccache-* | Qt*) ;;
*) continue ;;
esac
reason=""
if [[ $ref != refs/heads/master ]]; then
reason="scoped to $ref"
elif [[ $key == vcpkg/* ]]; then
# "<scope>/<abi hash>", cut the hash off the end to get the scope
scope="vcpkg/${key#vcpkg/}"
scope="${scope%/*}/"
if [[ $live != *" $scope "* ]]; then
reason="no matrix leg uses $scope"
fi
fi
if [[ -z $reason ]]; then
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
if [[ $stamp -ne 0 && $stamp -lt $cutoff ]]; then
reason="unused since $accessed"
fi
fi
if [[ -n $reason ]]; then
echo "::notice::deleting $key, $reason"
gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id"
deleted=$((deleted + 1))
fi
done <"$RUNNER_TEMP/caches.tsv"
echo "::notice::deleted $deleted of $(wc -l <"$RUNNER_TEMP/caches.tsv") cache entries"

View file

@ -1,121 +0,0 @@
name: Import vcpkg cache
# After a runner image update changes every port's ABI hash, the first master run
# rebuilds protobuf, openssl and abseil from source and saves them into the shared
# cache, where every later run can restore them.
#
# Only master pushes are imported here: the collect step, keys.txt and the zip
# contents all come from the workflow run being imported, so importing from an
# unmerged pull request would let anyone who can push a branch — or a bot actor
# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into
# the scope that master and tag builds restore and link into signed releases.
# Forks build untrusted code and must never reach that scope either.
#
# repository_dispatch is not used on purpose: it would also resolve to the default branch,
# but it counts as a low-trust trigger and may only write there when it declares a
# write-capable cache-mode, which puts it in the same position as workflow_run.
permissions:
actions: write # needed to save entries into the default branch's cache scope
contents: read
on:
workflow_run:
workflows: ["Build Desktop"]
types: [completed]
# Every import writes the same scopes, so let them queue instead of racing each other
concurrency:
group: vcpkg-cache-import
cancel-in-progress: false
jobs:
list:
name: List new vcpkg entries
# Only a successful push to the default branch has archives worth importing.
# Only code that landed on master may populate the scope master runs restore.
# Bot actors are excluded on top: their runs must never promote binaries into
# the shared scope even if they somehow target the default branch.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!endsWith(github.event.workflow_run.actor.login, '[bot]') &&
!endsWith(github.event.workflow_run.triggering_actor.login, '[bot]')
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
# workflow_run is a low-trust trigger, so cache access is read-only by default even
# though this run resolves to the default branch. Assert below that this took effect,
# because a save under read-only access only warns and would import nothing silently.
cache-mode: write
outputs:
entries: ${{ steps.scan.outputs.entries }}
steps:
- name: "Download new vcpkg entries"
uses: actions/download-artifact@v8
with:
pattern: vcpkg-new-*
path: incoming
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: "Assert write access"
shell: bash
run: |
if [[ $ACTIONS_CACHE_MODE != *write* ]]; then
echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
exit 1
fi
- name: "List cache entries"
id: scan
shell: bash
working-directory: incoming
run: |
entries="$(find . -name keys.txt -type f -exec sh -c '
for manifest; do
artifact="$(basename "$(dirname "$manifest")")"
while read -r key path; do
printf "{\"artifact\": \"%s\", \"key\": \"%s\", \"path\": \"%s\"}\n" \
"$artifact" "$key" "$path"
done <"$manifest"
done
' _ {} +)"
[[ -n $entries ]] || exit 0
# Compact, because a multi line value cannot be written to GITHUB_OUTPUT
entries="$(jq -cs '.' <<<"$entries")"
echo "entries=$entries" >>"$GITHUB_OUTPUT"
echo "::notice::importing $(jq length <<<"$entries") vcpkg entries"
save:
name: "Save ${{ matrix.entry.key }}"
needs: list
if: needs.list.outputs.entries
runs-on: ubuntu-slim
cache-mode: write
strategy:
# One job per archive, they run in parallel and each only uploads a single file
fail-fast: false
max-parallel: 20
matrix:
entry: ${{ fromJSON(needs.list.outputs.entries) }}
steps:
- name: "Download new vcpkg entries"
uses: actions/download-artifact@v8
with:
pattern: ${{ matrix.entry.artifact }}
path: incoming
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: "Save vcpkg entry"
uses: actions/cache/save@v6
with:
# The payload is a Windows or macOS archive while the upload runs on Linux, and
# without this the entry would be stamped as a Linux cache that neither of them
# is allowed to restore.
enableCrossOsArchive: true
key: ${{ matrix.entry.key }}
path: incoming/${{ matrix.entry.path }}

View file

@ -90,7 +90,7 @@ endif()
# A project name is needed for CPack
# Version can be overriden by git tags, see cmake/getversion.cmake
project("Cockatrice" VERSION 3.1.2)
project("Cockatrice" VERSION 3.1.1)
# Set release name if not provided via env/cmake var
if(NOT DEFINED GIT_TAG_RELEASENAME)

View file

@ -10,7 +10,6 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QIODevice>
#include <QList>
#include <QLocale>
@ -178,15 +177,7 @@ bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
// Data written, so reload the card database
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
// Spoilers can introduce sets the config has never seen; the loader never
// runs checkUnknownSets() itself, so run it once the finished snapshot has
// been swapped in.
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
watcher->deleteLater();
CardDatabaseManager::getInstance()->checkUnknownSets();
});
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
const auto reloadOk = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
// If the user has notifications enabled, let them know
// when the database was last updated

View file

@ -133,10 +133,6 @@ SettingsCache::SettingsCache()
// Migrate settings from global.ini to per-class INI files (runs at most once)
SettingsMigration::migrateSettingsFromGlobalIni(settingsPath);
// Repair gamefilters.ini keys renamed to camelCase by #7084 for profiles that migrated
// before that rename (idempotent, safe to run on every startup, portable builds included).
SettingsMigration::migrateGameFiltersToCamelCase(settingsPath);
shortcutsSettings = new ShortcutsSettings(settingsPath, this);
cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this);
serversSettings = new ServersSettings(settingsPath, this);

View file

@ -17,9 +17,7 @@ struct GameFilterConfigs
static constexpr int DEFAULT_MAX_PLAYERS_MAX = 99;
bool hideBuddiesOnlyGames = false;
// Defaults to true to match GameFiltersSettings::isHideIgnoredUserGames(), so freshly loaded
// filters compare equal to a default-constructed config.
bool hideIgnoredUserGames = true;
bool hideIgnoredUserGames = false;
bool hideFullGames = false;
bool hideGamesThatStarted = false;
bool hidePasswordProtectedGames = false;

View file

@ -113,17 +113,6 @@ GameSelector::GameSelector(AbstractClient *_client,
quickFilterToolBar->setVisible(
SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar());
});
// The quick-filter toolbar only updates the in-memory model, so persist every change.
// Otherwise each new GameSelector (room switch, reconnect, restart) would revert to stale
// saved values and e.g. hide full/started games the user had unchecked. The save is
// debounced so typing in the search field does not rewrite the settings file on every
// keystroke; a pending save is flushed in the destructor.
filterSaveDebounceTimer.setSingleShot(true);
filterSaveDebounceTimer.setInterval(500);
connect(&filterSaveDebounceTimer, &QTimer::timeout, this,
[this] { gameListProxyModel->saveFilterParameters(gameTypeMap); });
connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this,
[this] { filterSaveDebounceTimer.start(); });
} else {
quickFilterToolBar = nullptr;
}
@ -198,18 +187,6 @@ GameSelector::GameSelector(AbstractClient *_client,
connect(client, &AbstractClient::removeFromListEventReceived, this, &GameSelector::processRemoveFromListEvent);
}
GameSelector::~GameSelector()
{
// Nothing else persists quick-filter toolbar edits: the toolbar only updates the in-memory model,
// and the debounced timer above is the sole writer for that path (actSetFilter/actClearFilter save
// synchronously, but the toolbar does not). Flush a pending debounced save so edits made within
// the debounce interval before destruction are not lost on room switch, reconnect, or close.
if (filterSaveDebounceTimer.isActive()) {
filterSaveDebounceTimer.stop();
gameListProxyModel->saveFilterParameters(gameTypeMap);
}
}
void GameSelector::ignoreListReceived(const QList<ServerInfo_User> &)
{
gameListProxyModel->refresh();

View file

@ -4,7 +4,6 @@
#include "game_type_map.h"
#include <QGroupBox>
#include <QTimer>
#include <qtmetamacros.h>
// IWYU pragma: keep
// ServerInfo_User appears by value and inside QList in slot signatures, so the
@ -149,9 +148,6 @@ private:
const bool showFilters; /**< Determines whether filter buttons are displayed. */
GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */
QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings
file on every keystroke. */
/**
* @brief Updates the widget title to reflect the current number of displayed games.
*
@ -215,7 +211,6 @@ public:
const bool restoresettings,
const bool _showfilters,
QWidget *parent = nullptr);
~GameSelector() override;
/**
* @brief Updates UI text for translation/localization.

View file

@ -523,9 +523,6 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const
void GamesProxyModel::refresh()
{
#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
beginFilterChange();
#endif
#if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0))
endFilterChange(QSortFilterProxyModel::Direction::Rows);
#else

View file

@ -70,7 +70,6 @@
#include <QFileDialog>
#include <QFileInfo>
#include <QFlags>
#include <QFutureWatcher>
#include <QIODevice>
#include <QLocalSocket>
#include <QMenu>
@ -641,10 +640,10 @@ void MainWindow::startupConfigCheck()
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
// The loader never runs checkUnknownSets() itself (see main.cpp): the
// front-loaded parse runs before MainWindow exists, so
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no
// receivers. MainWindow owns the check after startup.
// checkUnknownSets() is intentionally deferred from the card database load
// (which runs in main() before MainWindow exists) so that
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live
// receivers when emitted.
// On a clean install the onboarding wizard owns the first-run experience;
// wait until it closes so the legacy "all sets enabled" welcome and the
// Manage Sets dialog don't appear first.
@ -1138,35 +1137,18 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow
void MainWindow::cardDatabaseAllNewSetsEnabled()
{
auto *cardDatabase = CardDatabaseManager::getInstance();
// With no card data there is nothing to have enabled and nothing to reparse.
if (cardDatabase->getCardList().isEmpty()) {
if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) {
// The onboarding wizard owns the first-run messaging on a clean install,
// and with no card data there are no sets to have enabled.
return;
}
// checkUnknownSets() enabled every set after the database had already been
// parsed under the (empty) first-run enablement, so the parser dropped every
// printing of every set. Reparse now that the sets are enabled, otherwise the
// deck editor's card database display stays empty until the next launch.
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [this, watcher] {
watcher->deleteLater();
if (firstRunWizardActive) {
// The onboarding wizard owns the first-run messaging on a clean install.
return;
}
QMessageBox::information(
this, tr("Welcome"),
tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the "
"card "
"database have been enabled.\nRead more about changing the set order or disabling specific sets and "
"consequent effects in the \"Manage Sets\" dialog."));
// The reload above replaces the set instances, so Manage Sets must open
// only after the finished snapshot has been swapped in.
actManageSets();
});
watcher->setFuture(QtConcurrent::run([cardDatabase] { cardDatabase->reloadCardDatabasesAndNotify(); }));
QMessageBox::information(
this, tr("Welcome"),
tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the card "
"database have been enabled.\nRead more about changing the set order or disabling specific sets and "
"consequent effects in the \"Manage Sets\" dialog."));
actManageSets();
}
/* CARD UPDATER */
@ -1339,19 +1321,7 @@ void MainWindow::exitCardDatabaseUpdate()
cardUpdateProcess = nullptr;
statusBar()->clearMessage();
// This can be the first successful load of the session (e.g. the onboarding
// download), so it must own the set check for that load.
reloadCardDatabaseAndCheckSets();
}
void MainWindow::reloadCardDatabaseAndCheckSets()
{
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
watcher->deleteLater();
CardDatabaseManager::getInstance()->checkUnknownSets();
});
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
}
void MainWindow::cardUpdateError(QProcess::ProcessError err)
@ -1553,8 +1523,7 @@ void MainWindow::actAddCustomSet()
QMessageBox::information(
this, tr("Load sets/cards"),
tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database."));
// Imported files can introduce sets the config has never seen.
reloadCardDatabaseAndCheckSets();
const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
} else {
QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import."));
}

View file

@ -158,14 +158,6 @@ private:
}
void createCardUpdateProcess(bool background = false);
void exitCardDatabaseUpdate();
/**
* @brief Reloads the card database on a worker thread and runs checkUnknownSets() once the finished
* snapshot has been swapped in.
*
* The loader never runs checkUnknownSets() itself (see main.cpp); post-update loads that can introduce
* new sets must own the check, and it has to run after the queued snapshot swap, not on the worker.
*/
void reloadCardDatabaseAndCheckSets();
/**
* @brief Tries to bring the open card database updater window to the front.
* @return true when the updater acknowledged the request

View file

@ -343,12 +343,11 @@ int main(int argc, char *argv[])
// contention that happens when the load runs alongside window construction.
// The CardDatabaseModel populates from the already-loaded data in its
// constructor, so the window appears fully populated with no startup lag.
// Note: checkUnknownSets() is deliberately not run for this front-loaded
// parse. It would run before MainWindow exists, so
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no
// receivers, and enabling the sets up-front would suppress the first-run
// experience MainWindow::startupConfigCheck() and the onboarding wizard
// provide. Callers that load after startup own the check.
// Note: checkUnknownSets() is deferred from the initial load to
// MainWindow::startupConfigCheck() so that
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live
// receivers when emitted. Subsequent reloads (e.g. path changes) call
// checkUnknownSets() directly from the loader after the first load.
CardDatabaseManager::getInstance()->loadCardDatabases();
MainWindow ui;

View file

@ -38,18 +38,7 @@ Selecting this action will open a new text editor dialog with the contents of yo
The import dialog expects each line to be a card with the following format:
`[quantity] Card Name [options]`
- `quantity` is the number of copies and can be written as `2`, `2x` or `2X`. If omitted, a single copy is imported.
- `options` are appended after the card name and are understood on a best-effort basis:
- a set code and collector number, either as `Card Name (SET) 123` or `Card Name (SET) 123-1`
- a foil marker, `Card Name *F*`
- Lines can also carry a `SB:` prefix or a set code prefix (`[SET]`) — Cockatrice tries to stay compatible with the most
common deck formats.
Tab-separated exports from third-party apps (such as Delver Lens) are also accepted: any line whose first column is a
plain quantity treats the following columns as the card name and printer/set metadata (which is discarded), for example
`2\tCard Name\tSet Name`.
TODO
Each card should be on a separate line and there should be no empty lines between cards. The first empty line between
two blocks of cards will be considered as the divider between mainboard and sideboard.

View file

@ -13,7 +13,6 @@
#include <QDebug>
#include <QList>
#include <QMessageLogger>
#include <QMetaObject>
#include <QMetaType>
#include <QMutexLocker>
#include <QSharedPointer>
@ -81,17 +80,15 @@ void CardDatabase::reloadCardDatabasesAndNotify()
{
// An explicit reload bypasses the binary cache so the files on disk are
// always re-read, even when their size/mtime hash still matches the cache.
// Models refresh from the swap's cardDatabaseReset in a single batch, so
// no cardDatabaseEnabledSetsChanged is emitted here: that rebuilt the
// 36k-card models a second time on the GUI thread, which froze startup
// after enabling new/custom sets. The follow-up unknown-sets check is
// queued to the GUI thread so it sees the swapped-in data (this may run
// on a worker via QtConcurrent::run); it is silent unless new sets
// appeared mid-reload.
loadStatus = loader->loadCardDatabases(/* ignoreCache */ true);
if (loadStatus == Ok) {
QMetaObject::invokeMethod(this, &CardDatabase::checkUnknownSets, Qt::AutoConnection);
checkUnknownSets();
// A reload reconstructs the exact same enabled-set state, so the cached
// set-name / alt-name data computed during construction is still valid.
// Skipping the per-card refresh avoids a full 36k-card recompute that
// would otherwise duplicate the work already done while building cards.
notifyEnabledSetsChanged(false);
}
}
@ -210,19 +207,15 @@ void CardDatabase::checkUnknownSets()
if (numUnknownSets > 0) {
emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames);
} else {
_sets.markAllAsKnownBatched(setPriorityController);
_sets.markAllAsKnown();
}
} else {
// No set enabled. Probably this is the first time running trice
_sets.enableAllBatched(setPriorityController);
_sets.guessSortKeys();
_sets.sortByKey();
_sets.enableAll();
notifyEnabledSetsChanged();
// No cardDatabaseEnabledSetsChanged here: the reload chained off
// cardDatabaseAllNewSetsEnabled re-parses with the sets enabled and its
// swap's cardDatabaseReset rebuilds the models in one batch. Emitting
// both back-to-back rebuilt the 36k-card models twice on the GUI thread,
// stalling startup. (When the card list is empty the slot returns early
// without reloading, but there is nothing to display either.)
emit cardDatabaseAllNewSetsEnabled();
}
}
@ -230,13 +223,13 @@ void CardDatabase::checkUnknownSets()
void CardDatabase::enableAllUnknownSets()
{
auto _sets = getSetList();
_sets.enableAllUnknownBatched(setPriorityController);
_sets.enableAllUnknown();
}
void CardDatabase::markAllSetsAsKnown()
{
auto _sets = getSetList();
_sets.markAllAsKnownBatched(setPriorityController);
_sets.markAllAsKnown();
}
void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets)

View file

@ -44,13 +44,7 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent,
// the finished snapshot is swapped into the live database on the GUI thread.
// when SettingsCache's path changes, trigger reloads
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] {
loadCardDatabases();
// This reload runs synchronously on the emitting (GUI) thread, so the
// finished snapshot is already swapped in when it returns: enable any
// sets the newly pointed-at database introduced.
database->checkUnknownSets();
});
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { loadCardDatabases(); });
}
CardDatabaseLoader::~CardDatabaseLoader()
@ -142,23 +136,7 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
loadCardDatabase(p, data);
}
// A snapshot parsed while no set is enabled has had every printing
// stripped by the parsers, and the cache reader re-derives enablement
// from the priority controller, so such an entry can never be reused
// meaningfully. Skip writing it so a first-run (blank config) load
// cannot poison the cache with an empty snapshot. The check mirrors
// what the parsers saw: any set still reporting enabled means the
// snapshot is complete for the current enablement.
bool anySetEnabled = false;
for (const CardSetPtr &set : data.sets) {
if (set && set->getEnabled()) {
anySetEnabled = true;
break;
}
}
if (!data.sets.isEmpty() && !anySetEnabled) {
qCInfo(CardDatabaseLoadingLog) << "Skipping binary cache: no set is enabled yet, snapshot was stripped";
} else if (!saveToCache(data, sourceHash)) {
if (!saveToCache(data, sourceHash)) {
qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath();
}
}
@ -170,15 +148,18 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
database->refreshCachedReverseRelatedCards(data.cards);
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success";
// NOTE: checkUnknownSets() is intentionally NOT called here. During the
// front-loaded parse in main() MainWindow does not exist yet, so
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would be
// emitted with no receivers, and enabling the sets up-front would
// suppress the first-run experience MainWindow::startupConfigCheck()
// and the onboarding wizard are designed around. Callers that load
// after startup own the check (and any reload it triggers).
emit databaseDataReady(std::move(data));
emit loadingFinished();
// During the front-loaded parse in main() this runs before MainWindow
// exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled
// would be emitted with no receivers. Skip the check on the first load;
// MainWindow::startupConfigCheck() calls checkUnknownSets() once its
// signal connections are live. On subsequent reloads (e.g. path change)
// MainWindow exists and signals have live receivers.
if (initialLoadComplete) {
database->checkUnknownSets();
}
initialLoadComplete = true;
} else {
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed";
emit loadingFailed(); // bring up the settings dialog

View file

@ -179,6 +179,7 @@ private:
QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */
QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */
bool initialLoadComplete = false; /**< Set after the first successful load. */
};
#endif // COCKATRICE_CARD_DATABASE_LOADER_H

View file

@ -227,10 +227,6 @@ public:
* @param _isknown True if known, false if unknown.
*/
void setIsKnown(bool _isknown);
void setIsKnownInMemory(bool _isknown)
{
isknown = _isknown;
}
/**
* @brief Determines whether the set has incomplete metadata and should be ignored.

View file

@ -1,11 +1,9 @@
#include "card_set_list.h"
#include "libcockatrice/card/set/card_set.h"
#include "libcockatrice/interfaces/interface_card_set_priority_controller.h"
#include <QDate>
#include <QString>
#include <QVector>
#include <algorithm>
#include <compare>
@ -64,87 +62,60 @@ QStringList CardSetList::getUnknownSetsNames()
return sets;
}
void CardSetList::enableAllUnknownBatched(ICardSetPriorityController *controller)
void CardSetList::enableAllUnknown()
{
QVector<ICardSetPriorityController::SetSaveData> saveData;
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set.isNull()) {
continue;
if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnown(true);
set->setEnabled(true);
} else if (set && set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabled(true);
}
bool changed = false;
if (!set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnownInMemory(true);
set->setEnabledInMemory(true);
changed = true;
} else if (set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabledInMemory(true);
changed = true;
}
if (changed) {
saveData.append(
{set->getShortName(), static_cast<unsigned int>(set->getSortKey()), true, set->getIsKnown()});
}
}
if (!saveData.isEmpty()) {
controller->saveSets(saveData);
}
}
void CardSetList::enableAllBatched(ICardSetPriorityController *controller)
void CardSetList::enableAll()
{
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set == nullptr) {
// qCWarning(CardInfoLog) << "enabledAll has null";
continue;
}
if (!set->getIsKnownIgnored()) {
set->setIsKnown(true);
}
set->setEnabled(true);
}
}
void CardSetList::markAllAsKnown()
{
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnown(true);
set->setEnabled(false);
} else if (set && set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabled(true);
}
}
}
void CardSetList::guessSortKeys()
{
defaultSort();
QVector<ICardSetPriorityController::SetSaveData> saveData;
saveData.reserve(size());
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set.isNull()) {
// qCWarning(CardInfoLog) << "guessSortKeys set is null";
continue;
}
const auto sortKey = static_cast<unsigned int>(i);
set->setSortKeyInMemory(sortKey);
if (!set->getIsKnownIgnored()) {
set->setIsKnownInMemory(true);
}
set->setEnabledInMemory(true);
saveData.append({set->getShortName(), sortKey, true, set->getIsKnown()});
}
if (!saveData.isEmpty()) {
controller->saveSets(saveData);
}
}
void CardSetList::markAllAsKnownBatched(ICardSetPriorityController *controller)
{
QVector<ICardSetPriorityController::SetSaveData> saveData;
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set.isNull()) {
continue;
}
bool changed = false;
if (!set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnownInMemory(true);
set->setEnabledInMemory(false);
changed = true;
} else if (set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabledInMemory(true);
changed = true;
}
if (changed) {
saveData.append({set->getShortName(), static_cast<unsigned int>(set->getSortKey()), set->getEnabled(),
set->getIsKnown()});
}
}
if (!saveData.isEmpty()) {
controller->saveSets(saveData);
set->setSortKey(i);
}
}

View file

@ -40,37 +40,36 @@ public:
void sortByKey();
/**
* @brief Enables every set with fresh sort keys, persisting in one flush.
* @brief Reassigns sort keys based on the current order.
*
* Assigns sequential sort keys in default-sorted order and marks every set
* known and enabled, writing all records through the controller's batched
* saveSets() in a single settings flush instead of ~3 QSettings rewrite
* cycles per set, which froze the GUI thread for seconds on a first run
* with a full database.
* @param controller The set priority controller to persist the records with.
* Calls defaultSort() and then assigns sequential sort keys
* to all sets according to their resulting positions, replacing
* any existing sort keys to ensure consistent ordering.
*/
void enableAllBatched(ICardSetPriorityController *controller);
void guessSortKeys();
/**
* @brief Enables all sets that are unknown or ignored, persisting in one flush.
* @brief Enables all sets that are unknown or ignored.
*
* Sets that are not marked as known and not ignored are marked as known
* and enabled. Ignored-known sets are also enabled, but remain ignored.
* Only changed sets are written, so the common case of a few new sets costs
* a single settings flush no matter how large the database is.
* @param controller The set priority controller to persist the records with.
*/
void enableAllUnknownBatched(ICardSetPriorityController *controller);
void enableAllUnknown();
/**
* @brief Marks all sets as known and adjusts their enabled state, persisting in one flush.
* @brief Enables all sets in the list.
*
* Equivalent to calling `setEnabled(true)` on each entry.
*/
void enableAll();
/**
* @brief Marks all sets as known and adjusts their enabled state.
*
* Unknown, non-ignored sets become known and disabled.
* Ignored-known sets are enabled if they were previously disabled.
* Only changed sets are written; when nothing is unknown no flush happens at all.
* @param controller The set priority controller to persist the records with.
*/
void markAllAsKnownBatched(ICardSetPriorityController *controller);
void markAllAsKnown();
/**
* @brief Counts the number of sets that are currently enabled.

View file

@ -33,7 +33,6 @@ bool parse(QTextStream &in,
// Regex for advanced card parsing
static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))");
static const QRegularExpression reTabQuantity(R"(^\d+[xX]?$)");
// Regex for extracting set code and collector number with attached symbols
static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
@ -111,34 +110,8 @@ bool parse(QTextStream &in,
continue;
}
const QString rawLine = match.captured();
QString cardName = rawLine.simplified();
QString cardName = match.captured().simplified();
bool sideboard = false;
int amount = 1;
// Card names never contain tab characters, so a leading numeric field
// marks a tabular record emitted by third-party deck tools (e.g. Delver
// Lens "2\tCard Name\tSet Name"). The first two columns are the quantity
// and the card name; any further columns are printer/set metadata and
// are discarded.
bool hasTabQuantity = false;
const auto tabFields = rawLine.split('\t');
int firstField = 0;
while (firstField < tabFields.size() && tabFields.at(firstField).trimmed().isEmpty()) {
++firstField;
}
if (firstField + 1 < tabFields.size()) {
const QString quantity = tabFields.at(firstField).trimmed();
if (reTabQuantity.match(quantity).hasMatch()) {
hasTabQuantity = true;
QString digits = quantity;
if (digits.endsWith('x') || digits.endsWith('X')) {
digits.chop(1);
}
amount = digits.toInt();
cardName = tabFields.at(firstField + 1).simplified();
}
}
// Sideboard detection
if (sBStart < 0) {
@ -183,12 +156,11 @@ bool parse(QTextStream &in,
}
// check if a specific amount is mentioned
if (!hasTabQuantity) {
match = reMultiplier.match(cardName);
if (match.hasMatch()) {
amount = match.captured(1).toInt();
cardName = match.captured(2);
}
int amount = 1;
match = reMultiplier.match(cardName);
if (match.hasMatch()) {
amount = match.captured(1).toInt();
cardName = match.captured(2);
}
// Normalize the card name

View file

@ -12,7 +12,6 @@ public:
QString shortName;
unsigned int sortKey;
bool enabled;
bool isKnown = false;
};
/**
@ -58,14 +57,6 @@ public:
*/
virtual QStringList getEnabledSetNames() const = 0;
/**
* @brief Persists per-set options for every entry in a single settings flush.
*
* Each individual setSortKey/setEnabled/setIsKnown call re-opens and re-syncs
* the whole .ini when backed by QSettings, so persisting hundreds of sets
* one option at a time freezes the GUI thread for seconds. Callers that touch
* more than a handful of sets must bundle the records and call this once.
*/
virtual void saveSets(const QVector<SetSaveData> &data) = 0;
};

View file

@ -266,7 +266,7 @@ void SetsModel::save(CardDatabase *db)
const bool enabled = enabledSets.contains(sets[i]);
sets[i]->setSortKeyInMemory(sortKey);
sets[i]->setEnabledInMemory(enabled);
saveData.append({sets[i]->getShortName(), sortKey, enabled, sets[i]->getIsKnown()});
saveData.append({sets[i]->getShortName(), sortKey, enabled});
}
db->getPriorityController()->saveSets(saveData);

View file

@ -3,7 +3,6 @@ set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
game/dump_zone_range.h
game/server_abstract_participant.h
game/server_abstract_player.h
game/server_arrow.h
@ -34,7 +33,6 @@ qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
add_library(
libcockatrice_network_server_remote STATIC
${MOC_SOURCES}
game/dump_zone_range.cpp
game/server_abstract_participant.cpp
game/server_abstract_player.cpp
game/server_arrow.cpp

View file

@ -1,17 +0,0 @@
#include "dump_zone_range.h"
#include <algorithm>
std::pair<qsizetype, qsizetype> dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed)
{
qsizetype count = 0;
if (numberCards == -1) {
count = cardCount;
} else if (numberCards > 0) {
count = std::min<qsizetype>(numberCards, cardCount);
}
count = std::max<qsizetype>(count, 0);
const qsizetype begin = isReversed ? cardCount - count : 0;
return {begin, begin + count};
}

View file

@ -1,29 +0,0 @@
#ifndef DUMP_ZONE_RANGE_H
#define DUMP_ZONE_RANGE_H
#include <QtGlobal>
#include <utility>
/**
* @brief Resolve a client's zone dump request against a zone's real card count.
*
* @c number_cards arrives from the client unvalidated. -1 means "the whole zone", which
* is the convention the client uses when sizing its own view of one. Every other negative
* value, and every value larger than the zone, has to be brought into range rather than
* used as an index: with a reversed request the zone offset is
* @c cardCount - @c numberCards, which runs off the front of the list when the request
* exceeds the zone and off the back when @c numberCards is -1.
*
* Kept in its own translation unit so it can be exercised without the rest of
* server_abstract_player.cpp, which refers to the RNG global that only the servatrice
* binary defines.
*
* @param cardCount number of cards the zone actually holds
* @param numberCards the requested count, -1 for the whole zone
* @param isReversed take the trailing cards instead of the leading ones
* @return the half-open index range [begin, end) into the zone's card list, always
* contained in [0, cardCount)
*/
std::pair<qsizetype, qsizetype> dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed);
#endif // DUMP_ZONE_RANGE_H

View file

@ -1,6 +1,5 @@
#include "server_abstract_player.h"
#include "dump_zone_range.h"
#include "game/server_abstract_participant.h"
#include "game/server_arrowtarget.h"
#include "libcockatrice/protocol/pb/card_attributes.pb.h"
@ -1436,23 +1435,23 @@ Server_AbstractPlayer::cmdDumpZone(const Command_DumpZone &cmd, ResponseContaine
int numberCards = cmd.number_cards();
const QList<Server_Card *> &cards = zone->getCards();
const auto cardRange = dumpZoneCardRange(cards.size(), numberCards, cmd.is_reversed());
auto *re = new Response_DumpZone;
ServerInfo_Zone *zoneInfo = re->mutable_zone_info();
zoneInfo->set_name(zone->getName().toStdString());
zoneInfo->set_type(zone->getType());
zoneInfo->set_with_coords(zone->hasCoords());
zoneInfo->set_card_count(cards.size());
zoneInfo->set_card_count(numberCards < cards.size() ? cards.size() : numberCards);
for (qsizetype findId = cardRange.first; findId < cardRange.second; ++findId) {
Server_Card *card = cards.at(findId);
for (int i = 0; (i < cards.size()) && (i < numberCards || numberCards == -1); ++i) {
const auto &findId = cmd.is_reversed() ? cards.size() - numberCards + i : i;
Server_Card *card = cards[findId];
QString displayedName = card->getFaceDown() ? QString() : card->getName();
ServerInfo_Card *cardInfo = zoneInfo->add_card_list();
cardInfo->set_provider_id(card->getProviderId().toStdString());
cardInfo->set_name(displayedName.toStdString());
if (zone->getType() == ServerInfo_Zone::HiddenZone) {
cardInfo->set_id(static_cast<int>(findId));
cardInfo->set_id(findId);
} else {
cardInfo->set_id(card->getId());
cardInfo->set_x(card->getX());

View file

@ -110,7 +110,6 @@ void CardDatabaseSettings::saveSets(const QVector<ICardSetPriorityController::Se
s.beginGroup(entry.shortName);
s.setValue("sortKey", entry.sortKey);
s.setValue("enabled", entry.enabled);
s.setValue("isKnown", entry.isKnown);
s.endGroup();
}
s.endGroup();
@ -122,6 +121,5 @@ void CardDatabaseSettings::saveSets(const QVector<ICardSetPriorityController::Se
SetOptions &o = setOptionsCache[entry.shortName];
o.sortKey = entry.sortKey;
o.enabled = entry.enabled;
o.isKnown = entry.isKnown;
}
}
}

View file

@ -642,34 +642,6 @@ static void migrateLegacyMessages(const QString &settingsPath)
legacySetting.endGroup();
}
/**
* gamefilters.ini keys renamed to camelCase by #7084. Profiles that migrated from the legacy
* native store before that rename still hold the old snake_case keys, which nothing reads
* anymore, so their saved filters silently stopped loading.
*/
static const QMap<QString, QString> &renamedGameFilterKeys()
{
static const QMap<QString, QString> keyMap = {
{"hide_buddies_only_games", "hideBuddiesOnlyGames"},
{"hide_full_games", "hideFullGames"},
{"hide_games_that_started", "hideGamesThatStarted"},
{"hide_password_protected_games", "hidePasswordProtectedGames"},
{"hide_ignored_user_games", "hideIgnoredUserGames"},
{"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"},
{"hide_open_decklist_games", "hideOpenDecklistGames"},
{"game_name_filter", "gameNameFilter"},
{"creator_name_filter", "hostNameFilter"},
{"min_players", "minPlayers"},
{"max_players", "maxPlayers"},
{"max_game_age_time", "maxGameAgeTime"},
{"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"},
{"show_spectator_password_protected", "showSpectatorPasswordProtected"},
{"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"},
{"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"},
};
return keyMap;
}
static void migrateLegacyGameFilters(const QString &settingsPath)
{
QSettings legacySetting;
@ -680,7 +652,24 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
return;
}
const QMap<QString, QString> &filterKeyMap = renamedGameFilterKeys();
const QMap<QString, QString> filterKeyMap = {
{"hide_buddies_only_games", "hideBuddiesOnlyGames"},
{"hide_full_games", "hideFullGames"},
{"hide_games_that_started", "hideGamesThatStarted"},
{"hide_password_protected_games", "hidePasswordProtectedGames"},
{"hide_ignored_user_games", "hideIgnoredUserGames"},
{"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"},
{"hide_open_decklist_games", "hideOpenDecklistGames"},
{"game_name_filter", "gameNameFilter"},
{"creator_name_filter", "creatorNameFilter"},
{"min_players", "minPlayers"},
{"max_players", "maxPlayers"},
{"max_game_age_time", "maxGameAgeTime"},
{"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"},
{"show_spectator_password_protected", "showSpectatorPasswordProtected"},
{"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"},
{"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"},
};
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) {
@ -697,68 +686,6 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
legacySetting.endGroup();
}
static const QString LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/game_type/");
static const QString GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/gameType/");
static bool gameFiltersCamelCaseMigrationPending(const QString &settingsPath)
{
if (!QFile::exists(settingsPath + "gamefilters.ini")) {
return false;
}
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
const QMap<QString, QString> &keyMap = renamedGameFilterKeys();
for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) {
if (filtersIni.contains("filter_games/" + it.key()) && !filtersIni.contains("filter_games/" + it.value())) {
return true;
}
}
// Orphaned by #7084, which migrated the legacy host-name filter to a key nothing reads.
if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) {
return true;
}
for (const auto &key : filtersIni.allKeys()) {
if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX) &&
!filtersIni.contains(GAME_FILTERS_GAME_TYPE_PREFIX +
key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size()))) {
return true;
}
}
return false;
}
bool SettingsMigration::migrateGameFiltersToCamelCase(const QString &settingsPath)
{
if (!gameFiltersCamelCaseMigrationPending(settingsPath)) {
return false;
}
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
const QMap<QString, QString> &keyMap = renamedGameFilterKeys();
for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) {
const QString oldKey = "filter_games/" + it.key();
const QString newKey = "filter_games/" + it.value();
if (filtersIni.contains(oldKey) && !filtersIni.contains(newKey)) {
filtersIni.setValue(newKey, filtersIni.value(oldKey));
}
}
if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) {
filtersIni.setValue("filter_games/hostNameFilter", filtersIni.value("filter_games/creatorNameFilter"));
}
for (const auto &key : filtersIni.allKeys()) {
if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX)) {
const QString newKey = GAME_FILTERS_GAME_TYPE_PREFIX + key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size());
if (!filtersIni.contains(newKey)) {
filtersIni.setValue(newKey, filtersIni.value(key));
}
}
}
// The old keys are deliberately kept: pre-#7084 builds still read them, so removing them
// would break downgrades, and nothing in the current code reads them anymore.
filtersIni.sync();
return true;
}
bool SettingsMigration::migrateLegacySettings(const QString &settingsPath)
{
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
@ -817,10 +744,6 @@ bool SettingsMigration::isMigrationPending(const QString &settingsPath)
return true;
}
if (gameFiltersCamelCaseMigrationPending(settingsPath)) {
return true;
}
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
if (personalIni.value("migration/legacy_complete", false).toBool()) {
return false;

View file

@ -9,15 +9,6 @@ public:
static bool migrateSettingsFromGlobalIni(const QString &settingsPath);
static bool migrateLegacySettings(const QString &settingsPath);
/**
* Moves pre-camelCase gamefilters.ini keys (snake_case, game_type/<hash>) to the camelCase
* keys used since #7084, for profiles that already migrated before that rename and therefore
* never pass through migrateLegacySettings again. Copies values only where the new key is
* absent, never deletes the old keys, and returns true when anything was copied. Idempotent,
* so it runs on every startup.
*/
static bool migrateGameFiltersToCamelCase(const QString &settingsPath);
/**
* Returns true when at least one of the two migrations would still do work, i.e. when a
* backup of the settings directory is worth taking before they run.

View file

@ -250,16 +250,6 @@ email=root@localhost
; Sender email name
name="Cockatrice server"
; Maximum number of attempts to deliver a given e-mail before giving up.
; If the smtp server is unreachable or repeatedly rejects a message, the server
; will retry with an exponentially growing delay up to this many times, then drop
; the message from the queue and log the failure. Default: 5
maxretries=5
; Initial retry delay in seconds between delivery attempts. Doubles with each
; failed attempt (capped at 30 minutes). Default: 60
retrydelay=60
; Email subject
subject="Cockatrice server account activation token"

View file

@ -38,7 +38,6 @@
#include <QDebug>
#include <QFile>
#include <QIODevice>
#include <QLoggingCategory>
#include <QMapIterator>
#include <QMetaObject>
#include <QMutableListIterator>
@ -70,8 +69,6 @@
#include <server_room.h>
#include <string>
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
int _numberPools,
const QSqlDatabase &_sqlDatabase,
@ -338,11 +335,6 @@ bool Servatrice::initServer()
servatriceDatabaseInterface->clearSessionTables();
}
// Connect the SMTP client's delivery-confirmation signals to the database cleanup handlers.
// Emails are removed from the queue tables only after delivery is confirmed (see statusUpdate()).
connect(smtpClient, &SmtpClient::mailDelivered, this, &Servatrice::onEmailDelivered);
connect(smtpClient, &SmtpClient::mailPermanentlyFailed, this, &Servatrice::onEmailPermanentlyFailed);
if (getRoomsMethodString() == "sql") {
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
"select id, name, descr, permissionlevel, privlevel, auto_join, join_message, chat_history_size from "
@ -745,36 +737,41 @@ void Servatrice::statusUpdate()
return;
}
// Rows are intentionally NOT deleted here: the durable source of truth for a pending
// activation e-mail is the database row, which is only removed once delivery has been
// confirmed by the SMTP client (see onEmailDelivered). This makes sending resilient to
// transient SMTP failures and server restarts.
auto *queryDelete =
servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name");
while (servDbSelQuery->next()) {
const QString userName = servDbSelQuery->value(0).toString();
const auto emailAddress = EmailParser::getParsedEmailAddress(servDbSelQuery->value(1).toString());
const QString token = servDbSelQuery->value(2).toString();
smtpClient->enqueueActivationTokenMail(userName, emailAddress, token);
if (smtpClient->enqueueActivationTokenMail(userName, emailAddress, token)) {
queryDelete->bindValue(":name", userName);
servatriceDatabaseInterface->execSqlQuery(queryDelete);
}
}
}
if (getEnableForgotPassword()) {
auto *forgotPwQuery = servatriceDatabaseInterface->prepareQuery(
"select a.id, a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on "
"a.name = b.name where a.emailed = 0");
"select a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on a.name "
"= b.name where a.emailed = 0");
if (!servatriceDatabaseInterface->execSqlQuery(forgotPwQuery)) {
return;
}
// Rows are intentionally left with emailed = 0 here; they are marked emailed only once
// delivery is confirmed by the SMTP client (see onEmailDelivered).
while (forgotPwQuery->next()) {
const qint64 rowId = forgotPwQuery->value(0).toLongLong();
const QString userName = forgotPwQuery->value(1).toString();
const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(2).toString());
const QString token = forgotPwQuery->value(3).toString();
QSqlQuery *queryDelete = servatriceDatabaseInterface->prepareQuery(
"update {prefix}_forgot_password set emailed = 1 where name = :name");
smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token, rowId);
while (forgotPwQuery->next()) {
const QString userName = forgotPwQuery->value(0).toString();
const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(1).toString());
const QString token = forgotPwQuery->value(2).toString();
if (smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token)) {
queryDelete->bindValue(":name", userName);
servatriceDatabaseInterface->execSqlQuery(queryDelete);
}
}
}
@ -782,69 +779,6 @@ void Servatrice::statusUpdate()
}
}
void Servatrice::onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId)
{
if (cleanupDatabaseForEmail(userName, type, token, rowId)) {
qCDebug(ServatriceLog) << "E-mail delivered to" << userName;
} else {
qCWarning(ServatriceLog) << "Failed to clean up database row for delivered e-mail to" << userName
<< "- row may be re-enqueued on next status update";
}
}
void Servatrice::onEmailPermanentlyFailed(const QString &userName,
EmailType type,
FailureReason reason,
const QString &token,
qint64 rowId)
{
switch (reason) {
case FailureReason::RetryExhausted:
qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed after max retries";
break;
case FailureReason::RecipientRejected:
qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: recipient rejected";
break;
case FailureReason::SenderRejected:
qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: sender rejected";
break;
}
if (!cleanupDatabaseForEmail(userName, type, token, rowId)) {
qCWarning(ServatriceLog) << "Failed to clean up database row for permanently failed e-mail to" << userName
<< "- row may be re-enqueued on next status update";
}
}
bool Servatrice::cleanupDatabaseForEmail(const QString &userName, EmailType type, const QString &token, qint64 rowId)
{
Q_UNUSED(token);
switch (type) {
case EmailType::Activation: {
QSqlQuery *query =
servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name");
query->bindValue(":name", userName);
return servatriceDatabaseInterface->execSqlQuery(query);
}
case EmailType::ForgotPassword: {
// Scope the update to the row that was actually sent: the table
// holds one row per request, and marking by name would also mark
// rows for newer tokens that were never delivered.
if (rowId >= 0) {
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
"update {prefix}_forgot_password set emailed = 1 where id = :id");
query->bindValue(":id", rowId);
return servatriceDatabaseInterface->execSqlQuery(query);
}
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
"update {prefix}_forgot_password set emailed = 1 where name = :name");
query->bindValue(":name", userName);
return servatriceDatabaseInterface->execSqlQuery(query);
}
}
return false;
}
SessionEvent *Servatrice::makeShutdownEvent() const
{
Event_ServerShutdown event;

View file

@ -21,7 +21,6 @@
#define SERVATRICE_H
#include "metrics_registry.h"
#include "smtpclient.h"
#include <QByteArray>
#include <QDateTime>
@ -182,7 +181,6 @@ private:
QMap<QString, bool> serverRequiredFeatureList;
QString officialWarnings;
Servatrice_DatabaseInterface *servatriceDatabaseInterface;
bool cleanupDatabaseForEmail(const QString &userName, EmailType type, const QString &token, qint64 rowId);
int serverId;
int uptime;
QMutex txBytesMutex, rxBytesMutex;
@ -233,12 +231,6 @@ public slots:
void scheduleShutdown(const QString &reason, int minutes);
void updateLoginMessage();
void setRequiredFeatures(const QString &featureList);
void onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId);
void onEmailPermanentlyFailed(const QString &userName,
EmailType type,
FailureReason reason,
const QString &token,
qint64 rowId);
public:
explicit Servatrice(QObject *parent = nullptr);

View file

@ -94,38 +94,6 @@ int QxtSmtp::pendingMessages() const
return qxt_d().pending.count();
}
/*!
* \brief Drops all queued (not yet transmitted) messages without sending them.
*
* Any message still awaiting delivery is discarded. This is used by callers that
* keep their own authoritative queue and wish to rebuild the SMTP buffer from scratch
* (e.g. after a reconnect) without risking duplicates.
*/
void QxtSmtp::clearPending()
{
qxt_d().pending.clear();
}
/*!
* \brief Clears the outgoing queue and resets the SMTP state machine.
*
* Unlike clearPending(), this also resets the internal state to Disconnected so
* that subsequent send() calls will not fire sendNext() prematurely on a
* not-yet-connected socket. Use this when rebuilding the queue from scratch
* before a fresh connectToHost() / connectToSecureHost() cycle.
*/
void QxtSmtp::reset()
{
qxt_d().pending.clear();
qxt_d().extensions.clear();
qxt_d().buffer.clear();
qxt_d().recipients.clear();
qxt_d().rcptNumber = 0;
qxt_d().rcptAck = 0;
qxt_d().mailAck = false;
qxt_d().state = QxtSmtpPrivate::Disconnected;
}
QTcpSocket *QxtSmtp::socket() const
{
return qxt_d().socket;

View file

@ -66,8 +66,6 @@ public:
int send(const QxtMailMessage& message);
int pendingMessages() const;
void clearPending();
void reset();
QTcpSocket* socket() const;
void connectToHost(const QString& hostName, quint16 port = 25);

View file

@ -7,56 +7,17 @@
#include <QAbstractSocket>
#include <QByteArray>
#include <QDebug>
#include <QLoggingCategory>
#include <QSharedPointer>
#include <QSslSocket>
#include <QString>
#include <QTcpSocket>
#include <QVariant>
#include <qlogging.h>
#include <qobjectdefs.h>
static const qint64 DEFAULT_MAX_RETRIES = 5;
static const qint64 DEFAULT_RETRY_DELAY = 60;
static const qint64 MAX_RETRY_DELAY = 1800; // 30 minutes
static const qint64 IDLE_TIMEOUT_MS = 60000; // 60 seconds
// Maximum bit-shift exponent used in the exponential back-off formula.
// With a base delay of 60 s, shift 10 gives 60 * 1024 ≈ 17 h before the
// MAX_RETRY_DELAY cap is applied, preventing unbounded intermediate values.
static const int MAX_BACKOFF_SHIFT = 10;
inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client")
static QString pendingKey(const QString &userName, EmailType type, const QString &token)
{
const char *typeName = (type == EmailType::Activation) ? "activation" : "forgotpassword";
// The token is part of the key: a regenerated token (forgot-password issues a
// fresh one per request) must not be suppressed by a stale pending entry.
return userName + '/' + typeName + '/' + token;
}
// True only for permanent 5xx replies. Anything else (4xx greylisting/rate
// limiting, or an unparsable reply) must be retried, never treated as final:
// destroying the durable queue row on a transient failure loses the mail.
static bool isPermanentSmtpReply(const QByteArray &msg)
{
for (char c : msg) {
if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '-') {
continue;
}
return c == '5';
}
return false;
}
SmtpClient::SmtpClient(QObject *parent) : QObject(parent)
{
smtp = new QxtSmtp(this);
idleTimer = new QTimer(this);
idleTimer->setSingleShot(true);
connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleTimeout()));
connect(smtp, SIGNAL(authenticated()), this, SLOT(authenticated()));
connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this,
SLOT(authenticationFailed(const QByteArray &)));
@ -79,151 +40,104 @@ SmtpClient::~SmtpClient()
{
if (smtp) {
delete smtp;
smtp = nullptr;
smtp = 0;
}
}
int SmtpClient::maxRetries() const
{
// Bounded like retryDelaySeconds(): an unset/blank value must not turn the
// first transient failure into a permanent one (attempts >= 0 is immediate).
return qBound(1, settingsCache->value("smtp/maxretries", DEFAULT_MAX_RETRIES).toInt(), 100);
}
int SmtpClient::retryDelaySeconds() const
{
return qBound(1, settingsCache->value("smtp/retrydelay", DEFAULT_RETRY_DELAY).toInt(),
static_cast<int>(MAX_RETRY_DELAY));
}
QDateTime SmtpClient::nextRetryTime(int attempts, int retryDelay) const
{
// Exponential backoff, capped to avoid hammering an unreachable server.
const int shift = qMax(0, qMin(attempts - 1, MAX_BACKOFF_SHIFT));
const qint64 delay = qMin<qint64>(retryDelay * qint64(1) << shift, MAX_RETRY_DELAY);
return QDateTime::currentDateTime().addSecs(delay);
}
SmtpClient::PendingEmail SmtpClient::buildEmail(const QString &nickname,
EmailType type,
const QString &subject,
const QString &body,
const QString &recipient,
const QString &token,
const QString &email,
qint64 rowId)
{
QString name = settingsCache->value("smtp/name", "").toString();
PendingEmail pending;
pending.userName = nickname;
pending.type = type;
pending.token = token;
pending.rowId = rowId;
pending.maxAttempts = maxRetries();
pending.retryDelay = retryDelaySeconds();
pending.nextAttempt = QDateTime::currentDateTime();
QString emailBody = body;
emailBody.replace("%username", nickname).replace("%token", token);
pending.message.setSender(name + " <" + email + ">");
pending.message.addRecipient(recipient);
pending.message.setSubject(subject);
pending.message.setBody(emailBody);
return pending;
}
bool SmtpClient::enqueue(const PendingEmail &email)
{
const QString key = pendingKey(email.userName, email.type, email.token);
if (pendingEmails.contains(key)) {
qCDebug(SmtpClientLog) << "Email already pending for" << email.userName << "- not duplicating";
return false;
}
// A fresher database row for the same user/type supersedes a stale pending
// entry: drop the stale one so only the current token is ever delivered.
// (Any in-flight mapping for the dropped key is removed as well; the
// mailSent/recordFailed lookups tolerate missing entries.)
for (auto it = pendingEmails.begin(); it != pendingEmails.end();) {
if (it.value().userName == email.userName && it.value().type == email.type) {
for (auto mapIt = mailIdToUser.begin(); mapIt != mailIdToUser.end();) {
if (mapIt.value() == it.key()) {
mapIt = mailIdToUser.erase(mapIt);
} else {
++mapIt;
}
}
qCDebug(SmtpClientLog) << "Replacing stale pending email for" << email.userName;
it = pendingEmails.erase(it);
} else {
++it;
}
}
pendingEmails.insert(key, email);
qCDebug(SmtpClientLog) << "Enqueued" << (email.type == EmailType::Activation ? "activation" : "password reset")
<< "mail to" << email.userName;
return true;
}
bool SmtpClient::enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token)
{
if (pendingEmails.contains(pendingKey(nickname, EmailType::Activation, token))) {
qCDebug(SmtpClientLog) << "Activation email already pending for" << nickname << "- skipping";
return false;
}
QString email = settingsCache->value("smtp/email", "").toString();
QString name = settingsCache->value("smtp/name", "").toString();
QString subject = settingsCache->value("smtp/subject", "").toString();
QString body = settingsCache->value("smtp/body", "").toString();
QString email = settingsCache->value("smtp/email", "").toString();
if (!validateEmailFields(email, subject, body, recipient, token, nickname)) {
if (email.isEmpty()) {
qDebug() << "[MAIL] Missing sender email in configuration";
return false;
}
return enqueue(buildEmail(nickname, EmailType::Activation, subject, body, recipient, token, email));
if (subject.isEmpty()) {
qDebug() << "[MAIL] Missing subject field in configuration";
return false;
}
if (body.isEmpty()) {
qDebug() << "[MAIL] Missing body field in configuration";
return false;
}
if (recipient.isEmpty()) {
qDebug() << "[MAIL] Missing recipient field for user " << nickname;
return false;
}
if (token.isEmpty()) {
qDebug() << "[MAIL] Missing token field for user " << nickname;
return false;
}
QxtMailMessage message;
message.setSender(name + " <" + email + ">");
message.addRecipient(recipient);
message.setSubject(subject);
message.setBody(body.replace("%username", nickname).replace("%token", token));
int id = smtp->send(message);
qDebug() << "[MAIL] Enqueued mail to" << recipient << "as" << id;
return true;
}
bool SmtpClient::enqueueForgotPasswordTokenMail(const QString &nickname,
const QString &recipient,
const QString &token,
qint64 rowId)
bool SmtpClient::enqueueForgotPasswordTokenMail(const QString &nickname, const QString &recipient, const QString &token)
{
if (pendingEmails.contains(pendingKey(nickname, EmailType::ForgotPassword, token))) {
qCDebug(SmtpClientLog) << "Password reset email already pending for" << nickname << "- skipping";
return false;
}
QString email = settingsCache->value("smtp/email", "").toString();
QString name = settingsCache->value("smtp/name", "").toString();
QString subject = settingsCache->value("forgotpassword/subject", "").toString();
QString body = settingsCache->value("forgotpassword/body", "").toString();
QString email = settingsCache->value("smtp/email", "").toString();
if (!validateEmailFields(email, subject, body, recipient, token, nickname)) {
if (email.isEmpty()) {
qDebug() << "[MAIL] Missing sender email in configuration";
return false;
}
return enqueue(buildEmail(nickname, EmailType::ForgotPassword, subject, body, recipient, token, email, rowId));
if (subject.isEmpty()) {
qDebug() << "[MAIL] Missing subject field in configuration";
return false;
}
if (body.isEmpty()) {
qDebug() << "[MAIL] Missing body field in configuration";
return false;
}
if (recipient.isEmpty()) {
qDebug() << "[MAIL] Missing recipient field for user " << nickname;
return false;
}
if (token.isEmpty()) {
qDebug() << "[MAIL] Missing token field for user " << nickname;
return false;
}
QxtMailMessage message;
message.setSender(name + " <" + email + ">");
message.addRecipient(recipient);
message.setSubject(subject);
message.setBody(body.replace("%username", nickname).replace("%token", token));
int id = smtp->send(message);
qDebug() << "[MAIL] Enqueued mail to" << recipient << "as" << id;
return true;
}
void SmtpClient::sendAllEmails()
{
// still connected from the previous round
if (smtp->socket()->state() == QAbstractSocket::ConnectedState ||
smtp->socket()->state() == QAbstractSocket::ConnectingState ||
smtp->socket()->state() == QAbstractSocket::HostLookupState ||
smtp->socket()->state() == QAbstractSocket::ClosingState) {
if (smtp->socket()->state() == QAbstractSocket::ConnectedState) {
return;
}
if (pendingEmails.isEmpty()) {
return;
}
// Respect connection-level backoff after auth/conn/encryption failures.
if (connectionAttempts > 0 && QDateTime::currentDateTime() < connectionBackoffUntil) {
if (smtp->pendingMessages() == 0) {
return;
}
@ -237,16 +151,6 @@ void SmtpClient::sendAllEmails()
smtp->setUsername(username);
smtp->setPassword(password);
// Push due emails into QxtSmtp *before* connecting so they are already
// queued when the authenticated() signal fires and QxtSmtp's internal
// sendNext() runs.
relayoutForSending();
// Nothing due right now — skip the SMTP round-trip.
if (mailIdToUser.isEmpty()) {
return;
}
// Connect
if (connectionType == "ssl") {
if (acceptAllCerts) {
@ -258,286 +162,64 @@ void SmtpClient::sendAllEmails()
}
}
void SmtpClient::relayoutForSending()
{
// Rebuild the SMTP buffer exclusively from our authoritative set, so a reconnect
// after a partial transmission never results in duplicate deliveries.
// reset() also clears the internal state machine so that send() does not
// fire sendNext() prematurely on a not-yet-connected socket.
smtp->reset();
mailIdToUser.clear();
const QDateTime now = QDateTime::currentDateTime();
for (auto it = pendingEmails.begin(); it != pendingEmails.end(); ++it) {
PendingEmail &pending = it.value();
if (pending.nextAttempt > now) {
continue; // not yet due; honours the backoff schedule
}
int id = smtp->send(pending.message);
mailIdToUser.insert(id, pendingKey(pending.userName, pending.type, pending.token));
}
}
void SmtpClient::finished()
{
qCDebug(SmtpClientLog) << "finished";
smtp->disconnectFromHost();
}
void SmtpClient::authenticated()
{
qCDebug(SmtpClientLog) << "authenticated";
// The session has actually succeeded (TCP connect alone proves nothing:
// TLS/auth may still fail), so the connection-level backoff resets here.
connectionAttempts = 0;
// Start the idle timer here, once the connection is fully ready to send,
// rather than on TCP connect: SSL/STARTTLS handshake and auth may take a
// while and must not be cut short by the idle timeout.
idleTimer->start(IDLE_TIMEOUT_MS);
qDebug() << "[MAIL] authenticated";
}
void SmtpClient::authenticationFailed(const QByteArray &msg)
{
qCWarning(SmtpClientLog) << "authenticationFailed" << QString::fromUtf8(msg);
idleTimer->stop();
recordConnectionFailure();
mailIdToUser.clear();
smtp->disconnectFromHost();
qDebug() << "[MAIL] authenticationFailed" << QString(msg);
}
void SmtpClient::connected()
{
qCDebug(SmtpClientLog) << "connected";
// NOTE: connectionAttempts is deliberately not reset here. This fires on
// TCP connect, before the TLS handshake and AUTH; resetting here would pin
// the connection-level backoff at its base delay for exactly the failures
// (bad password/cert/STARTTLS) it is meant to cover. It resets in
// authenticated() and on the first successful mailSent instead.
qDebug() << "[MAIL] connected";
}
void SmtpClient::connectionFailed(const QByteArray &msg)
{
qCWarning(SmtpClientLog) << "connectionFailed" << QString::fromUtf8(msg);
idleTimer->stop();
recordConnectionFailure();
mailIdToUser.clear();
smtp->disconnectFromHost();
qDebug() << "[MAIL] connectionFailed" << QString(msg);
}
void SmtpClient::disconnected()
{
qCDebug(SmtpClientLog) << "disconnected - emails still pending:" << pendingEmails.count();
idleTimer->stop();
// A mid-session drop (peer close/RST past the connect phase, or the idle
// timeout firing mid-batch) emits no mailFailed/connectionFailed signal, so
// route every in-flight mail through recordFailed(): otherwise attempts and
// backoff are bypassed and the mails requeue with stale state forever.
const QList<int> inFlight = mailIdToUser.keys();
for (int mailID : inFlight) {
recordFailed(mailID);
}
mailIdToUser.clear();
qDebug() << "[MAIL] disconnected";
}
void SmtpClient::encrypted()
{
qCDebug(SmtpClientLog) << "encrypted";
qDebug() << "[MAIL] encrypted";
}
void SmtpClient::encryptionFailed(const QByteArray &msg)
{
qCWarning(SmtpClientLog) << "encryptionFailed" << QString::fromUtf8(msg);
qCWarning(SmtpClientLog) << "Try enabling the \"acceptallcerts\" option in servatrice.ini";
idleTimer->stop();
recordConnectionFailure();
mailIdToUser.clear();
qDebug() << "[MAIL] encryptionFailed" << QString(msg);
qDebug() << "[MAIL] Try enabling the \"acceptallcerts\" option in servatrice.ini";
}
void SmtpClient::finished()
{
qDebug() << "[MAIL] finished";
smtp->disconnectFromHost();
}
void SmtpClient::idleTimeout()
{
qCDebug(SmtpClientLog) << "idle timeout - disconnecting";
smtp->disconnectFromHost();
}
void SmtpClient::recordFailed(int mailID)
{
auto it = mailIdToUser.find(mailID);
if (it == mailIdToUser.end()) {
return;
}
const QString key = it.value();
mailIdToUser.erase(it);
auto pendingIt = pendingEmails.find(key);
if (pendingIt == pendingEmails.end()) {
return;
}
PendingEmail &pending = pendingIt.value();
pending.attempts++;
if (pending.attempts >= pending.maxAttempts) {
const QString userName = pending.userName;
const EmailType type = pending.type;
const QString token = pending.token;
const qint64 rowId = pending.rowId;
qCWarning(SmtpClientLog) << "Email to" << userName << "permanently failed after" << pending.attempts
<< "attempts - dropping from pending set";
pendingEmails.erase(pendingIt);
emit mailPermanentlyFailed(userName, type, FailureReason::RetryExhausted, token, rowId);
return;
}
pending.nextAttempt = nextRetryTime(pending.attempts, pending.retryDelay);
qCWarning(SmtpClientLog) << "Email to" << pending.userName << "failed (attempt" << pending.attempts << "of"
<< pending.maxAttempts << "), retrying" << pending.nextAttempt.toString(Qt::ISODate);
}
void SmtpClient::recordConnectionFailure()
{
++connectionAttempts;
connectionBackoffUntil = nextRetryTime(connectionAttempts, retryDelaySeconds());
qCWarning(SmtpClientLog) << "Connection-layer failure #" << connectionAttempts << "- next attempt after"
<< connectionBackoffUntil.toString(Qt::ISODate);
// Connection failures count toward the per-email retry budget so that
// undeliverable mail eventually gives up rather than retrying forever.
QList<QString> keysToRemove;
for (auto it = pendingEmails.begin(); it != pendingEmails.end(); ++it) {
PendingEmail &pending = it.value();
pending.attempts++;
if (pending.attempts >= pending.maxAttempts) {
qCWarning(SmtpClientLog) << "Email to" << pending.userName << "permanently failed after" << pending.attempts
<< "connection failures - dropping from pending set";
keysToRemove.append(it.key());
emit mailPermanentlyFailed(pending.userName, pending.type, FailureReason::RetryExhausted, pending.token,
pending.rowId);
}
}
for (const QString &key : keysToRemove) {
pendingEmails.remove(key);
}
}
bool SmtpClient::validateEmailFields(const QString &email,
const QString &subject,
const QString &body,
const QString &recipient,
const QString &token,
const QString &nickname)
{
if (email.isEmpty()) {
qCCritical(SmtpClientLog) << "Missing sender email in configuration";
return false;
}
if (subject.isEmpty()) {
qCCritical(SmtpClientLog) << "Missing subject field in configuration";
return false;
}
if (body.isEmpty()) {
qCCritical(SmtpClientLog) << "Missing body field in configuration";
return false;
}
if (recipient.isEmpty()) {
qCCritical(SmtpClientLog) << "Missing recipient field for user" << nickname;
return false;
}
if (token.isEmpty()) {
qCCritical(SmtpClientLog) << "Missing token field for user" << nickname;
return false;
}
return true;
}
void SmtpClient::mailFailed(int mailID, int errorCode, const QByteArray &msg)
{
qCWarning(SmtpClientLog) << "mailFailed id=" << mailID << " errorCode=" << errorCode
<< "msg=" << QString::fromUtf8(msg);
// Progress was made (the server answered), so the idle timer measures from
// here rather than acting as a cap on the whole session.
idleTimer->start(IDLE_TIMEOUT_MS);
recordFailed(mailID);
qDebug() << "[MAIL] mailFailed id=" << mailID << " errorCode=" << errorCode << "msg=" << QString(msg);
}
void SmtpClient::mailSent(int mailID)
{
qCDebug(SmtpClientLog) << "mailSent" << mailID;
// A delivered mail proves the session works: reset the connection-level
// backoff and restart the idle timer from this point of progress.
connectionAttempts = 0;
idleTimer->start(IDLE_TIMEOUT_MS);
auto it = mailIdToUser.find(mailID);
if (it == mailIdToUser.end()) {
return;
}
const QString key = it.value();
mailIdToUser.erase(it);
auto pendingIt = pendingEmails.find(key);
if (pendingIt == pendingEmails.end()) {
return;
}
const QString userName = pendingIt.value().userName;
const EmailType type = pendingIt.value().type;
const QString token = pendingIt.value().token;
const qint64 rowId = pendingIt.value().rowId;
pendingEmails.erase(pendingIt);
qCDebug(SmtpClientLog) << "Delivered email to" << userName;
emit mailDelivered(userName, type, token, rowId);
qDebug() << "[MAIL] mailSent" << mailID;
}
void SmtpClient::recipientRejected(int mailID, const QString &address, const QByteArray &msg)
{
qCWarning(SmtpClientLog) << "recipientRejected id=" << mailID << " address=" << address
<< "msg=" << QString::fromUtf8(msg);
if (!isPermanentSmtpReply(msg)) {
// Transient 4xx (e.g. greylisting): consume one attempt and reschedule
// with backoff instead of destroying the durable queue row.
recordFailed(mailID);
return;
}
// A rejected recipient is a permanent condition; consider the mail undeliverable.
auto it = mailIdToUser.find(mailID);
if (it != mailIdToUser.end()) {
const QString key = it.value();
if (auto pendingIt = pendingEmails.find(key); pendingIt != pendingEmails.end()) {
const QString userName = pendingIt.value().userName;
const EmailType type = pendingIt.value().type;
const QString token = pendingIt.value().token;
const qint64 rowId = pendingIt.value().rowId;
pendingEmails.erase(pendingIt);
qCWarning(SmtpClientLog) << "Email to" << userName << "rejected - dropping from pending set";
emit mailPermanentlyFailed(userName, type, FailureReason::RecipientRejected, token, rowId);
}
mailIdToUser.erase(it);
}
qDebug() << "[MAIL] recipientRejected id=" << mailID << " address=" << address << "msg=" << QString(msg);
}
void SmtpClient::senderRejected(int mailID, const QString &address, const QByteArray &msg)
{
qCWarning(SmtpClientLog) << "senderRejected id=" << mailID << " sender=" << address
<< "msg=" << QString::fromUtf8(msg);
if (!isPermanentSmtpReply(msg)) {
// Transient 4xx on MAIL FROM (e.g. rate limiting): retry with backoff.
recordFailed(mailID);
return;
}
// A rejected sender is a permanent condition; consider the mail undeliverable.
auto it = mailIdToUser.find(mailID);
if (it != mailIdToUser.end()) {
const QString key = it.value();
if (auto pendingIt = pendingEmails.find(key); pendingIt != pendingEmails.end()) {
const QString userName = pendingIt.value().userName;
const EmailType type = pendingIt.value().type;
const QString token = pendingIt.value().token;
const qint64 rowId = pendingIt.value().rowId;
pendingEmails.erase(pendingIt);
qCWarning(SmtpClientLog) << "Email to" << userName << "sender rejected - dropping from pending set";
emit mailPermanentlyFailed(userName, type, FailureReason::SenderRejected, token, rowId);
}
mailIdToUser.erase(it);
}
qDebug() << "[MAIL] senderRejected id=" << mailID << " address=" << address << "msg=" << QString(msg);
}

View file

@ -1,67 +1,26 @@
#ifndef SMTPCLIENT_H
#define SMTPCLIENT_H
#include "smtp/qxtmailmessage.h"
#include <QDateTime>
#include <QHash>
#include <QObject>
#include <QString>
#include <QTimer>
#include <qtmetamacros.h>
class QxtSmtp;
class QByteArray;
/**
* @brief Types of e-mail the SMTP client is able to deliver.
*/
enum class EmailType
{
Activation,
ForgotPassword
};
Q_DECLARE_METATYPE(EmailType)
/**
* @brief Why a permanent e-mail delivery failure occurred.
*/
enum class FailureReason
{
RetryExhausted,
RecipientRejected,
SenderRejected
};
Q_DECLARE_METATYPE(FailureReason)
class SmtpClient : public QObject
{
Q_OBJECT
public:
SmtpClient(QObject *parent = nullptr);
~SmtpClient() override;
public slots:
bool enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token);
bool enqueueForgotPasswordTokenMail(const QString &nickname,
const QString &recipient,
const QString &token,
qint64 rowId = -1);
void sendAllEmails();
signals:
void mailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId);
void mailPermanentlyFailed(const QString &userName,
EmailType type,
FailureReason reason,
const QString &token,
qint64 rowId);
SmtpClient(QObject *parent = 0);
~SmtpClient();
protected:
QxtSmtp *smtp;
public slots:
bool enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token);
bool enqueueForgotPasswordTokenMail(const QString &nickname, const QString &recipient, const QString &token);
void sendAllEmails();
protected slots:
void authenticated();
void authenticationFailed(const QByteArray &msg);
@ -71,55 +30,10 @@ protected slots:
void encrypted();
void encryptionFailed(const QByteArray &msg);
void finished();
void idleTimeout();
void mailFailed(int mailID, int errorCode, const QByteArray &msg);
void mailSent(int mailID);
void recipientRejected(int mailID, const QString &address, const QByteArray &msg);
void senderRejected(int mailID, const QString &address, const QByteArray &msg);
private:
struct PendingEmail
{
QString userName;
EmailType type;
QString token;
// Database row id for tables that allow multiple rows per user
// ({prefix}_forgot_password); -1 when the name alone identifies the row.
qint64 rowId = -1;
QxtMailMessage message;
int attempts = 0;
int maxAttempts = 0;
int retryDelay = 0;
QDateTime nextAttempt;
};
PendingEmail buildEmail(const QString &nickname,
EmailType type,
const QString &subject,
const QString &body,
const QString &recipient,
const QString &token,
const QString &email,
qint64 rowId = -1);
bool enqueue(const PendingEmail &email);
void relayoutForSending();
QDateTime nextRetryTime(int attempts, int retryDelay) const;
void recordFailed(int mailID);
void recordConnectionFailure();
bool validateEmailFields(const QString &email,
const QString &subject,
const QString &body,
const QString &recipient,
const QString &token,
const QString &nickname);
int maxRetries() const;
int retryDelaySeconds() const;
QHash<QString, PendingEmail> pendingEmails;
QHash<int, QString> mailIdToUser;
QTimer *idleTimer;
int connectionAttempts = 0;
QDateTime connectionBackoffUntil;
};
#endif
#endif

View file

@ -206,17 +206,6 @@ cockatrice_add_test(
LABELS
server
)
cockatrice_add_test(
NAME
server_dump_zone_test
SOURCES
server_dump_zone_test.cpp
LIBS
libcockatrice_rng
libcockatrice_network
LABELS
server
)
cockatrice_add_test(
NAME
server_counter_test

View file

@ -159,19 +159,16 @@ public:
void setSortKey(QString shortName, unsigned int sortKey) override
{
++individualWrites;
setOptions[shortName].sortKey = sortKey;
}
void setEnabled(QString shortName, bool enabled) override
{
++individualWrites;
setOptions[shortName].enabled = enabled;
}
void setIsKnown(QString shortName, bool isknown) override
{
++individualWrites;
setOptions[shortName].isKnown = isknown;
}
@ -209,17 +206,12 @@ public:
void saveSets(const QVector<SetSaveData> &data) override
{
++batchedWrites;
for (const SetSaveData &entry : data) {
setOptions[entry.shortName].sortKey = entry.sortKey;
setOptions[entry.shortName].enabled = entry.enabled;
setOptions[entry.shortName].isKnown = entry.isKnown;
}
}
int individualWrites = 0; ///< Per-set/per-option controller writes (the slow path).
int batchedWrites = 0; ///< Batched saveSets() flushes (the fast path).
private:
QHash<QString, SetOptions> setOptions;
};
@ -383,131 +375,6 @@ TEST_F(CardDatabaseCacheInvalidationTest, ExplicitReloadBypassesCache)
EXPECT_FALSE(db.getCardList().value("Cat")->getSets().contains("CAT"));
EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("DOG"));
}
TEST_F(CardDatabaseCacheInvalidationTest, FirstRunWithNoSetsEnabledSkipsCacheWrite)
{
// A blank config: the parser discovers every set, but none of them has an
// ini entry, so all of them default to disabled (first run).
SeededSetPriorityController blankController;
NoopCardPreferenceProvider prefs;
SandboxPathProvider pathProvider(root);
CardDatabase db(nullptr, &prefs, &pathProvider, &blankController);
db.loadCardDatabases();
ASSERT_FALSE(db.getCardList().value("Cat").isNull()) << "cards are parsed even while their sets are disabled";
EXPECT_TRUE(db.getCardList().value("Cat")->getSets().isEmpty())
<< "no printings survive an all-sets-disabled parse";
EXPECT_TRUE(cacheContents().isEmpty()) << "a stripped first-run snapshot must not be cached";
}
TEST_F(CardDatabaseCacheInvalidationTest, EnablingAllSetsAfterFirstRunRestoresPrintings)
{
SeededSetPriorityController blankController;
NoopCardPreferenceProvider prefs;
SandboxPathProvider pathProvider(root);
CardDatabase db(nullptr, &prefs, &pathProvider, &blankController);
db.loadCardDatabases();
ASSERT_TRUE(cacheContents().isEmpty());
// This mirrors what checkUnknownSets() does on a first run: enable every
// set, then reload so the parser keeps the printings and the complete
// snapshot gets cached under the enabled-set hash.
for (const CardSetPtr &set : db.getSetList()) {
blankController.setEnabled(set->getShortName(), true);
}
db.reloadCardDatabasesAndNotify();
ASSERT_FALSE(db.getCardList().value("Cat").isNull());
EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT"));
EXPECT_FALSE(cacheContents().isEmpty()) << "the enabled-set snapshot must be cached";
}
TEST_F(CardDatabaseCacheInvalidationTest, CheckUnknownSetsBatchesFirstRunEnableAll)
{
// Same first-run starting point as above, but driven through the real
// checkUnknownSets() path, which must persist the enable-all with a single
// batched saveSets() instead of per-set/per-option controller writes.
SeededSetPriorityController blankController;
NoopCardPreferenceProvider prefs;
SandboxPathProvider pathProvider(root);
CardDatabase db(nullptr, &prefs, &pathProvider, &blankController);
db.loadCardDatabases();
ASSERT_TRUE(db.getSetList().getEnabledSetsNum() == 0);
db.checkUnknownSets();
EXPECT_EQ(blankController.individualWrites, 0) << "first-run enable-all must not write per set";
EXPECT_EQ(blankController.batchedWrites, 1) << "first-run enable-all must persist with a single flush";
for (const CardSetPtr &set : db.getSetList()) {
EXPECT_TRUE(set->getEnabled()) << set->getShortName().toStdString();
EXPECT_TRUE(blankController.isEnabled(set->getShortName())) << set->getShortName().toStdString();
if (!set->getIsKnownIgnored()) {
EXPECT_TRUE(set->getIsKnown()) << set->getShortName().toStdString();
EXPECT_TRUE(blankController.isKnown(set->getShortName())) << set->getShortName().toStdString();
}
}
// The follow-up reload re-parses with the sets enabled, restoring printings.
db.reloadCardDatabasesAndNotify();
ASSERT_FALSE(db.getCardList().value("Cat").isNull());
EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT"));
}
TEST_F(CardDatabaseCacheInvalidationTest, EnableAllUnknownSetsIsBatched)
{
// The "new sets found" dialog path: enabling the unknown delta must persist
// with a single flush no matter how many sets the database holds, so users
// with hundreds of custom sets don't stall the GUI thread. Built directly
// on CardSet objects because the fixture sets carry no metadata (and would
// all count as ignored).
SeededSetPriorityController controller;
CardSetList sets;
sets.append(
CardSet::newInstance(&controller, "AAA", "Alpha Set", "core", QDate(2020, 1, 1), CardSet::PriorityPrimary));
sets.append(
CardSet::newInstance(&controller, "BBB", "Beta Set", "expansion", QDate(2021, 6, 1), CardSet::PriorityPrimary));
sets.append(CardSet::newInstance(&controller, "XXX")); // no metadata: ignored
sets.enableAllUnknownBatched(&controller);
EXPECT_EQ(controller.individualWrites, 0) << "unknown-set enable must not write per set";
EXPECT_EQ(controller.batchedWrites, 1) << "unknown-set enable must persist with a single flush";
for (const CardSetPtr &set : sets) {
EXPECT_TRUE(set->getEnabled()) << set->getShortName().toStdString();
EXPECT_TRUE(controller.isEnabled(set->getShortName())) << set->getShortName().toStdString();
if (set->getIsKnownIgnored()) {
EXPECT_FALSE(set->getIsKnown()) << set->getShortName().toStdString();
} else {
EXPECT_TRUE(set->getIsKnown()) << set->getShortName().toStdString();
EXPECT_TRUE(controller.isKnown(set->getShortName())) << set->getShortName().toStdString();
}
}
}
TEST_F(CardDatabaseCacheInvalidationTest, MarkAllSetsAsKnownIsBatched)
{
// The dialog's "No" path: same single-flush requirement as enabling.
SeededSetPriorityController controller;
CardSetList sets;
sets.append(
CardSet::newInstance(&controller, "AAA", "Alpha Set", "core", QDate(2020, 1, 1), CardSet::PriorityPrimary));
sets.append(
CardSet::newInstance(&controller, "BBB", "Beta Set", "expansion", QDate(2021, 6, 1), CardSet::PriorityPrimary));
sets.append(CardSet::newInstance(&controller, "XXX")); // no metadata: ignored
sets.markAllAsKnownBatched(&controller);
EXPECT_EQ(controller.individualWrites, 0) << "mark-known must not write per set";
EXPECT_EQ(controller.batchedWrites, 1) << "mark-known must persist with a single flush";
EXPECT_TRUE(controller.isKnown("AAA"));
EXPECT_TRUE(controller.isKnown("BBB"));
EXPECT_FALSE(controller.isEnabled("AAA"));
EXPECT_FALSE(controller.isEnabled("BBB"));
// Ignored sets keep their known state but are enabled.
EXPECT_TRUE(controller.isEnabled("XXX"));
}
} // namespace
int main(int argc, char **argv)

View file

@ -80,20 +80,6 @@ TEST(LoadingFromClipboardTest, WeirdWhitespaceIsIgnored)
testDeck(clipboard, result);
}
TEST(LoadingFromClipboardTest, TabSeparatedImport)
{
QString clipboard("2\tMystic Snake\tStreets of New Capenna\n"
"3x\tCounterspell\tNPH\n"
"\t2\tSol Ring\tFMB\n"
"1\tForest\n"
"\n"
"2x\tDoom Blade\tM11\n");
Result result("", "", {{"Mystic Snake", 2}, {"Counterspell", 3}, {"Sol Ring", 2}, {"Forest", 1}},
{{"Doom Blade", 2}});
testDeck(clipboard, result);
}
TEST(LoadingFromClipboardTest, RemoveBlankEntriesFromBeginningAndEnd)
{
QString clipboard("\n"

View file

@ -1,125 +0,0 @@
/** @file server_dump_zone_test.cpp
* @brief Tests for resolving a client's zone dump request against a zone's card count.
* @ingroup Tests
*/
#include <QList>
#include <climits>
#include <gtest/gtest.h>
#include <libcockatrice/network/server/remote/game/dump_zone_range.h>
#include <vector>
namespace
{
/// Indices a dump request resolves to, as the handler would walk them.
std::vector<qsizetype> walked(qsizetype cardCount, int numberCards, bool isReversed)
{
const auto [begin, end] = dumpZoneCardRange(cardCount, numberCards, isReversed);
std::vector<qsizetype> out;
for (qsizetype i = begin; i < end; ++i) {
out.push_back(i);
}
return out;
}
} // namespace
TEST(ServerDumpZone, ForwardTakesLeadingCards)
{
EXPECT_EQ(walked(5, 2, false), (std::vector<qsizetype>{0, 1}));
}
TEST(ServerDumpZone, ForwardTakingAllCardsCoversTheZone)
{
EXPECT_EQ(walked(3, 3, false), (std::vector<qsizetype>{0, 1, 2}));
}
TEST(ServerDumpZone, ForwardAskingForOneDumpsOne)
{
EXPECT_EQ(walked(3, 1, false), (std::vector<qsizetype>{0}));
}
TEST(ServerDumpZone, MinusOneMeansTheWholeZone)
{
EXPECT_EQ(walked(4, -1, false), (std::vector<qsizetype>{0, 1, 2, 3}));
}
TEST(ServerDumpZone, ReversedTakesTrailingCards)
{
EXPECT_EQ(walked(5, 2, true), (std::vector<qsizetype>{3, 4}));
}
TEST(ServerDumpZone, ReversedAskingForOneTakesTheLast)
{
EXPECT_EQ(walked(5, 1, true), (std::vector<qsizetype>{4}));
}
TEST(ServerDumpZone, ZeroDumpsNothing)
{
EXPECT_TRUE(walked(5, 0, false).empty());
EXPECT_TRUE(walked(5, 0, true).empty());
}
// A request for more cards than the zone holds is clamped rather than walked off the end.
// Forward, the old loop stopped at the zone's size on its own.
TEST(ServerDumpZone, ForwardOversizedRequestIsClamped)
{
EXPECT_EQ(walked(3, 1000, false), (std::vector<qsizetype>{0, 1, 2}));
}
// Reversed, an oversized request put the offset at cardCount - numberCards, which is
// negative, so the walk started before the first card.
TEST(ServerDumpZone, ReversedOversizedRequestIsClamped)
{
EXPECT_EQ(walked(3, 1000, true), (std::vector<qsizetype>{0, 1, 2}));
}
// Reversed, -1 put the offset at cardCount + 1, so the walk started one past the last card.
TEST(ServerDumpZone, ReversedWholeZoneRequestStaysInRange)
{
EXPECT_EQ(walked(3, -1, true), (std::vector<qsizetype>{0, 1, 2}));
}
// Only -1 means "the whole zone". Any other negative count is not a request for anything,
// so it resolves to an empty range rather than to the whole zone.
TEST(ServerDumpZone, OtherNegativeCountsDumpNothing)
{
EXPECT_TRUE(walked(5, -2, false).empty());
EXPECT_TRUE(walked(5, -2, true).empty());
EXPECT_TRUE(walked(5, INT_MIN, true).empty());
}
TEST(ServerDumpZone, EmptyZoneYieldsAnEmptyRange)
{
EXPECT_TRUE(walked(0, -1, false).empty());
EXPECT_TRUE(walked(0, -1, true).empty());
EXPECT_TRUE(walked(0, 10, false).empty());
EXPECT_TRUE(walked(0, 10, true).empty());
}
TEST(ServerDumpZone, NonPositiveCardCountYieldsAnEmptyRange)
{
EXPECT_TRUE(walked(-1, -1, false).empty());
EXPECT_TRUE(walked(-1, -1, true).empty());
}
// The invariant the handler depends on: whatever a client asks for, the indices it walks
// are a subset of the zone's cards and never step outside the list. number_cards is an
// unbounded client-supplied sint32, so this sweeps the edges rather than a chosen few.
TEST(ServerDumpZone, ResolvedRangeIsAlwaysWithinTheZone)
{
const QList<int> cardCounts = {0, 1, 2, 3, 7, 60, 1000000};
const QList<int> requests = {INT_MIN, -1000, -2, -1, 0, 1, 2, 3, 59, 60, 61, 999999, INT_MAX};
for (int cardCount : cardCounts) {
for (int request : requests) {
for (bool isReversed : {false, true}) {
const auto [begin, end] = dumpZoneCardRange(cardCount, request, isReversed);
SCOPED_TRACE(testing::Message()
<< "cardCount=" << cardCount << " request=" << request << " isReversed=" << isReversed);
ASSERT_GE(begin, 0);
ASSERT_GE(end, begin);
ASSERT_LE(end, cardCount);
}
}
}
}

View file

@ -705,71 +705,6 @@ TEST_F(SettingsMigrationTest, LegacyMigrationCamelCasesKeys)
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true));
}
TEST_F(SettingsMigrationTest, LegacyMigrationMapsCreatorNameFilterToHostNameFilter)
{
{
QSettings nativeSettings;
nativeSettings.setValue("filter_games/creator_name_filter", QStringList({"Alice", "Bob"}));
nativeSettings.sync();
}
ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath));
ASSERT_TRUE(fileExists("gamefilters.ini"));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice", "Bob"})));
}
TEST_F(SettingsMigrationTest, GameFiltersIniSnakeCaseMigratesToCamelCase)
{
{
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
filtersIni.setValue("filter_games/hide_full_games", true);
filtersIni.setValue("filter_games/hide_games_that_started", true);
filtersIni.setValue("filter_games/game_name_filter", "test game");
filtersIni.setValue("filter_games/creator_name_filter", QStringList({"Alice"}));
filtersIni.setValue("filter_games/game_type/deadbeef", true);
filtersIni.sync();
}
ASSERT_TRUE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(true));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideGamesThatStarted"), QVariant(true));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameNameFilter"), QVariant("test game"));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice"})));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true));
// Idempotent: a second run has nothing left to do
ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath));
}
TEST_F(SettingsMigrationTest, GameFiltersIniOrphanCreatorNameFilterMigratesToHostName)
{
{
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
filtersIni.setValue("filter_games/creatorNameFilter", QStringList({"Alice"}));
filtersIni.sync();
}
ASSERT_TRUE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice"})));
ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath));
}
TEST_F(SettingsMigrationTest, GameFiltersIniKeepsNewerCamelCaseValues)
{
{
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
filtersIni.setValue("filter_games/hide_full_games", true);
filtersIni.setValue("filter_games/hideFullGames", false);
filtersIni.sync();
}
// Nothing to do: the current-format key already exists and must win
ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath));
ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(false));
}
TEST_F(SettingsMigrationTest, LegacyMigrationEmptyNativeFormatWritesSentinel)
{
QSettings().clear();